@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
@@ -2,16 +2,28 @@
2
2
  import { untrack } from 'svelte';
3
3
  import { Badge } from '../ui/badge/index.js';
4
4
  import { Button } from '../ui/button/index.js';
5
+ import {
6
+ ResizableHandle,
7
+ ResizablePane,
8
+ ResizablePaneGroup
9
+ } from '../ui/resizable/index.js';
5
10
  import { t } from '../../i18n/index.svelte.js';
6
11
  import type { Tab } from '../../types';
7
12
  import { buildHttpsUrl } from '../../utils/url.js';
8
13
  import { getUrlView, updateUrlView } from '../../utils/url-state.js';
9
14
  import {
10
- fetchConsolidated,
15
+ computeChunkCount,
16
+ computeChunkSize,
17
+ computeUncompressed,
18
+ DIM_LIKE_NAMES,
19
+ extractZarrStoreUrl,
20
+ fetchHierarchy,
21
+ formatChunkKeys,
22
+ formatCodecs,
11
23
  formatShape,
12
- probeWithZarrita,
13
- type VarMeta,
14
- type ZarrMetadata
24
+ inferDims,
25
+ type ZarrHierarchy,
26
+ type ZarrNode
15
27
  } from '../../utils/zarr.js';
16
28
 
17
29
  let { tab }: { tab: Tab } = $props();
@@ -21,17 +33,68 @@ let error = $state<string | null>(null);
21
33
  const urlView = getUrlView();
22
34
  let viewMode = $state<'inspect' | 'map'>(urlView === 'map' ? 'map' : 'inspect');
23
35
 
24
- let storeAttrs = $state<Record<string, any>>({});
25
- let variables = $state<VarMeta[]>([]);
26
- let coordVars = $state<VarMeta[]>([]);
27
- let spatialRefAttrs = $state<Record<string, any> | null>(null);
28
- let selectedNode = $state<VarMeta | null>(null);
29
- let zarrVersion = $state<number | null>(null);
36
+ let hierarchy = $state.raw<ZarrHierarchy | null>(null);
37
+ let selectedNode = $state<ZarrNode | null>(null);
38
+ /** Set of expanded node paths for the tree view. */
39
+ let expanded = $state(new Set<string>());
40
+
41
+ const hasStoreAttrs = $derived(hierarchy ? Object.keys(hierarchy.storeAttrs).length > 0 : false);
42
+ /** When true, detail panel shows store attrs instead of node details. */
43
+ let showingStoreAttrs = $state(false);
44
+
45
+ /**
46
+ * Build mapArrays: only include variables that zarr-layer can actually render.
47
+ * All dimensions must have resolvable coordinate arrays at the store root,
48
+ * because @carbonplan/zarr-layer resolves coordinates from root.
49
+ */
50
+ const mapArrays = $derived.by(() => {
51
+ if (!hierarchy) return [];
52
+ // Collect root-level array names (coordinate arrays that zarr-layer can find)
53
+ const rootArrayNames = new Set(
54
+ hierarchy.root.children.filter((c) => c.kind === 'array').map((c) => c.name)
55
+ );
56
+ const spatialNames = new Set(['x', 'y', 'lat', 'lon', 'latitude', 'longitude']);
57
+ const result: ZarrNode[] = [];
58
+ function walk(n: ZarrNode) {
59
+ if (n.kind === 'array' && n.shape && n.shape.length >= 2) {
60
+ const dims = n.dims?.length ? n.dims : inferDims(n.name, n.shape);
61
+ // Must have at least one spatial dim pair
62
+ let hasLat = false;
63
+ let hasLon = false;
64
+ for (const d of dims) {
65
+ const lower = d.toLowerCase();
66
+ if (lower === 'y' || lower === 'lat' || lower === 'latitude') hasLat = true;
67
+ if (lower === 'x' || lower === 'lon' || lower === 'longitude') hasLon = true;
68
+ }
69
+ if (hasLat && hasLon) {
70
+ // Non-spatial dims must have root-level coordinate arrays
71
+ const nonSpatialResolvable = dims.every(
72
+ (d) => spatialNames.has(d.toLowerCase()) || rootArrayNames.has(d)
73
+ );
74
+ if (nonSpatialResolvable) result.push(n);
75
+ }
76
+ }
77
+ for (const c of n.children) walk(c);
78
+ }
79
+ walk(hierarchy.root);
80
+ return result;
81
+ });
82
+
83
+ const coordArrays = $derived.by(() => {
84
+ if (!hierarchy) return [];
85
+ const result: ZarrNode[] = [];
86
+ function walk(n: ZarrNode) {
87
+ if (n.kind === 'array' && (DIM_LIKE_NAMES.has(n.name) || (n.shape?.length ?? 0) <= 1))
88
+ result.push(n);
89
+ for (const c of n.children) walk(c);
90
+ }
91
+ walk(hierarchy.root);
92
+ return result;
93
+ });
30
94
 
31
- const mapVars = $derived(variables.filter((v) => v.shape.length >= 2));
32
- const hasMapVars = $derived(mapVars.length > 0);
95
+ const hasMapVars = $derived(mapArrays.length > 0);
33
96
 
34
- // Reset view mode when tab changes (component reuse across zarr-type tabs)
97
+ // Reset view mode when tab changes
35
98
  let prevTabId = '';
36
99
  $effect(() => {
37
100
  const id = tab.id;
@@ -46,7 +109,7 @@ $effect(() => {
46
109
  if (!tab) return;
47
110
  const _tabId = tab.id;
48
111
  untrack(() => {
49
- loadZarrMetadata();
112
+ loadHierarchy();
50
113
  });
51
114
  });
52
115
 
@@ -55,27 +118,22 @@ function setViewMode(mode: 'inspect' | 'map') {
55
118
  updateUrlView(viewMode);
56
119
  }
57
120
 
58
- async function loadZarrMetadata() {
121
+ async function loadHierarchy() {
59
122
  loading = true;
60
123
  error = null;
61
124
 
62
125
  try {
63
- const url = buildHttpsUrl(tab)
64
- .replace(/\/zarr\.json$/, '')
65
- .replace(/\/+$/, '');
66
-
67
- let meta: ZarrMetadata | null = await fetchConsolidated(url);
68
-
69
- if (!meta) {
70
- meta = await probeWithZarrita(url, tab.name.replace(/\.(zarr|zr3)$/, ''));
71
- }
126
+ const rawUrl = buildHttpsUrl(tab).replace(/\/+$/, '');
127
+ const url = extractZarrStoreUrl(rawUrl) ?? rawUrl;
128
+ const storeName = tab.name.replace(/\.(zarr|zr3)$/, '');
72
129
 
73
- if (meta) {
74
- storeAttrs = meta.storeAttrs;
75
- variables = meta.variables;
76
- coordVars = meta.coords;
77
- spatialRefAttrs = meta.spatialRefAttrs;
78
- zarrVersion = meta.zarrVersion;
130
+ const h = await fetchHierarchy(url, storeName);
131
+ if (h) {
132
+ hierarchy = h;
133
+ selectedNode = null;
134
+ showingStoreAttrs = false;
135
+ // Auto-expand root children
136
+ expanded = new Set(['/']);
79
137
  }
80
138
  } catch (err) {
81
139
  error = err instanceof Error ? err.message : String(err);
@@ -84,173 +142,396 @@ async function loadZarrMetadata() {
84
142
  updateUrlView(viewMode);
85
143
  }
86
144
  }
145
+
146
+ function toggleExpand(path: string) {
147
+ const next = new Set(expanded);
148
+ if (next.has(path)) {
149
+ next.delete(path);
150
+ } else {
151
+ next.add(path);
152
+ }
153
+ expanded = next;
154
+ }
155
+
156
+ function selectNode(node: ZarrNode) {
157
+ selectedNode = node;
158
+ showingStoreAttrs = false;
159
+ }
160
+
161
+ function selectStoreAttrs() {
162
+ selectedNode = null;
163
+ showingStoreAttrs = true;
164
+ }
87
165
  </script>
88
166
 
89
- <div class="flex h-full flex-col">
90
- <!-- Toolbar -->
91
- <div
92
- class="flex items-center gap-1 border-b border-zinc-200 px-2 py-1.5 sm:gap-2 sm:px-4 dark:border-zinc-800"
93
- >
94
- <span class="truncate max-w-[120px] text-sm font-medium text-zinc-700 sm:max-w-none dark:text-zinc-300">{tab.name}</span>
95
- <Badge variant="secondary" class="bg-purple-100 text-purple-600 dark:bg-purple-900 dark:text-purple-300">{t('zarr.badge')}</Badge>
96
-
97
- {#if variables.length > 0}
98
- <span class="hidden text-xs text-zinc-400 sm:inline">{variables.length} {t('zarr.variables')}</span>
99
- {/if}
167
+ {#snippet treeNode(node: ZarrNode, depth: number)}
168
+ {@const isExpanded = expanded.has(node.path)}
169
+ {@const hasChildren = node.children.length > 0}
170
+ {@const isSelected = !showingStoreAttrs && selectedNode?.path === node.path}
171
+ <div>
172
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
173
+ <div
174
+ class="flex w-full cursor-pointer items-center gap-1 py-1 pe-3 text-xs hover:bg-zinc-100 dark:hover:bg-zinc-800/50"
175
+ class:bg-blue-50={isSelected}
176
+ class:dark:bg-blue-950={isSelected}
177
+ style="padding-inline-start: {depth * 16 + 8}px"
178
+ role="treeitem"
179
+ tabindex="0"
180
+ aria-selected={isSelected}
181
+ aria-expanded={hasChildren ? isExpanded : undefined}
182
+ onclick={() => selectNode(node)}
183
+ >
184
+ <!-- Expand/collapse toggle -->
185
+ {#if hasChildren}
186
+ <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
187
+ <span
188
+ class="flex size-4 shrink-0 items-center justify-center rounded hover:bg-zinc-200 dark:hover:bg-zinc-700"
189
+ role="button"
190
+ tabindex="-1"
191
+ aria-label={isExpanded ? 'Collapse' : 'Expand'}
192
+ onclick={(e: MouseEvent) => {
193
+ e.stopPropagation();
194
+ toggleExpand(node.path);
195
+ }}
196
+ >
197
+ <svg
198
+ class="size-3 text-zinc-400 transition-transform"
199
+ class:rotate-90={isExpanded}
200
+ viewBox="0 0 16 16"
201
+ fill="currentColor"
202
+ >
203
+ <path
204
+ fill-rule="evenodd"
205
+ d="M6.22 4.22a.75.75 0 0 1 1.06 0l3.25 3.25a.75.75 0 0 1 0 1.06l-3.25 3.25a.75.75 0 0 1-1.06-1.06L8.94 8 6.22 5.28a.75.75 0 0 1 0-1.06Z"
206
+ clip-rule="evenodd"
207
+ />
208
+ </svg>
209
+ </span>
210
+ {:else}
211
+ <span class="size-4 shrink-0"></span>
212
+ {/if}
100
213
 
101
- <div class="ms-auto flex items-center gap-1">
102
- <Button
103
- variant={viewMode === 'inspect' ? 'secondary' : 'ghost'}
104
- size="sm"
105
- class="h-7 px-2 text-xs"
106
- onclick={() => setViewMode('inspect')}
214
+ <!-- Icon -->
215
+ {#if node.kind === 'group'}
216
+ <svg class="size-3.5 shrink-0 text-amber-500" viewBox="0 0 16 16" fill="currentColor">
217
+ <path
218
+ d="M1 3.5A1.5 1.5 0 0 1 2.5 2h3.879a1.5 1.5 0 0 1 1.06.44l1.122 1.12A1.5 1.5 0 0 0 9.62 4H13.5A1.5 1.5 0 0 1 15 5.5v7a1.5 1.5 0 0 1-1.5 1.5h-11A1.5 1.5 0 0 1 1 12.5v-9Z"
219
+ />
220
+ </svg>
221
+ {:else}
222
+ <svg class="size-3.5 shrink-0 text-blue-500" viewBox="0 0 16 16" fill="currentColor">
223
+ <path
224
+ fill-rule="evenodd"
225
+ d="M1.5 2A1.5 1.5 0 0 0 0 3.5v2A1.5 1.5 0 0 0 1.5 7h3A1.5 1.5 0 0 0 6 5.5V5h4v.5A1.5 1.5 0 0 0 11.5 7h3A1.5 1.5 0 0 0 16 5.5v-2A1.5 1.5 0 0 0 14.5 2h-3A1.5 1.5 0 0 0 10 3.5V4H6v-.5A1.5 1.5 0 0 0 4.5 2h-3ZM10 9.5A1.5 1.5 0 0 1 11.5 8h3A1.5 1.5 0 0 1 16 9.5v2a1.5 1.5 0 0 1-1.5 1.5h-3A1.5 1.5 0 0 1 10 11.5v-2ZM0 9.5A1.5 1.5 0 0 1 1.5 8h3A1.5 1.5 0 0 1 6 9.5v2A1.5 1.5 0 0 1 4.5 13h-3A1.5 1.5 0 0 1 0 11.5v-2Z"
226
+ clip-rule="evenodd"
227
+ />
228
+ </svg>
229
+ {/if}
230
+
231
+ <!-- Name -->
232
+ <span
233
+ class="truncate"
234
+ class:font-medium={node.kind === 'array'}
235
+ class:text-zinc-700={node.kind === 'array'}
236
+ class:dark:text-zinc-300={node.kind === 'array'}
237
+ class:text-zinc-600={node.kind === 'group'}
238
+ class:dark:text-zinc-400={node.kind === 'group'}
107
239
  >
108
- {t('zarr.inspect')}
109
- </Button>
110
- {#if hasMapVars}
111
- <Button
112
- variant={viewMode === 'map' ? 'secondary' : 'ghost'}
113
- size="sm"
114
- class="h-7 px-2 text-xs"
115
- onclick={() => setViewMode('map')}
116
- >
117
- {t('zarr.map')}
118
- </Button>
240
+ {node.path === '/' ? '/ (root)' : node.name}
241
+ </span>
242
+
243
+ <!-- Right badge -->
244
+ {#if node.kind === 'array' && node.dtype}
245
+ <span class="ms-auto shrink-0 text-[10px] tabular-nums text-muted-foreground">
246
+ {node.dtype}
247
+ </span>
248
+ {:else if node.kind === 'group'}
249
+ <span class="ms-auto shrink-0 text-[10px] text-muted-foreground">{t('zarr.group')}</span>
119
250
  {/if}
120
251
  </div>
252
+
253
+ <!-- Children (lazy: only render when expanded) -->
254
+ {#if hasChildren && isExpanded}
255
+ {#each node.children as child}
256
+ {@render treeNode(child, depth + 1)}
257
+ {/each}
258
+ {/if}
121
259
  </div>
260
+ {/snippet}
122
261
 
123
- <!-- Content -->
124
- <div class="flex min-h-0 flex-1 overflow-hidden">
125
- {#if loading}
126
- <div class="flex flex-1 items-center justify-center">
127
- <p class="text-sm text-zinc-400">{t('zarr.loading')}</p>
128
- </div>
129
- {:else if error}
130
- <div class="flex flex-1 items-center justify-center">
131
- <p class="max-w-md text-center text-sm text-red-400">{error}</p>
262
+ {#snippet nodeDetails()}
263
+ {#if showingStoreAttrs && hierarchy}
264
+ <div
265
+ class="shrink-0 border-b border-zinc-200 px-3 py-1.5 text-[11px] font-medium uppercase tracking-wider text-muted-foreground dark:border-zinc-800"
266
+ >
267
+ {t('zarr.storeAttributes')}
268
+ </div>
269
+ <div class="flex-1 overflow-auto p-3">
270
+ <div
271
+ class="rounded border border-zinc-200 bg-zinc-100 p-2 text-xs dark:border-zinc-700 dark:bg-zinc-800"
272
+ >
273
+ {#each Object.entries(hierarchy.storeAttrs) as [key, value]}
274
+ <div class="flex gap-2 py-0.5">
275
+ <span class="shrink-0 font-medium text-muted-foreground">{key}:</span>
276
+ <span class="break-all text-zinc-700 dark:text-zinc-300">
277
+ {typeof value === 'string' ? value : JSON.stringify(value)}
278
+ </span>
279
+ </div>
280
+ {/each}
132
281
  </div>
133
- {:else if viewMode === 'map' && hasMapVars}
134
- {#key viewMode}
135
- {#await import('./ZarrMapViewer.svelte') then ZarrMapViewer}
136
- <ZarrMapViewer.default {tab} variables={mapVars} {spatialRefAttrs} {zarrVersion} />
137
- {/await}
138
- {/key}
139
- {:else}
140
- <!-- Inspect mode -->
141
- <div class="flex flex-1 overflow-hidden">
142
- <!-- Variable list sidebar -->
143
- <div
144
- class="w-64 shrink-0 overflow-auto border-e border-zinc-200 bg-zinc-50 dark:border-zinc-800 dark:bg-zinc-900"
145
- >
146
- {#if Object.keys(storeAttrs).length > 0}
147
- <div class="border-b border-zinc-200 px-3 py-2 dark:border-zinc-800">
148
- <button
149
- class="w-full text-start text-xs font-medium text-zinc-500 hover:text-zinc-700 dark:text-zinc-400 dark:hover:text-zinc-200"
150
- onclick={() => (selectedNode = null)}
151
- >
152
- Store Attributes
153
- </button>
282
+ </div>
283
+ {:else if selectedNode}
284
+ <div
285
+ class="shrink-0 border-b border-zinc-200 px-3 py-1.5 text-[11px] font-medium uppercase tracking-wider text-muted-foreground dark:border-zinc-800"
286
+ >
287
+ {selectedNode.path}
288
+ </div>
289
+ <div class="flex-1 overflow-auto p-3">
290
+ <dl class="space-y-2 text-xs">
291
+ <div>
292
+ <dt class="text-muted-foreground">{t('zarr.nodeType')}</dt>
293
+ <dd class="font-mono text-[11px]">{selectedNode.kind}</dd>
294
+ </div>
295
+
296
+ {#if hierarchy}
297
+ <div>
298
+ <dt class="text-muted-foreground">{t('zarr.format')}</dt>
299
+ <dd class="font-mono text-[11px]">
300
+ {hierarchy.zarrVersion ? `v${hierarchy.zarrVersion}` : 'unknown'}
301
+ </dd>
302
+ </div>
303
+ {/if}
304
+
305
+ {#if selectedNode.shape}
306
+ <div>
307
+ <dt class="text-muted-foreground">{t('zarr.shape')}</dt>
308
+ <dd class="font-mono text-[11px]">{formatShape(selectedNode.shape)}</dd>
309
+ </div>
310
+ {/if}
311
+
312
+ {#if selectedNode.dims && selectedNode.dims.length > 0}
313
+ <div>
314
+ <dt class="text-muted-foreground">{t('zarr.dimensions')}</dt>
315
+ <dd class="font-mono text-[11px]">({selectedNode.dims.join(', ')})</dd>
316
+ </div>
317
+ {/if}
318
+
319
+ {#if selectedNode.dtype}
320
+ <div>
321
+ <dt class="text-muted-foreground">{t('zarr.dtype')}</dt>
322
+ <dd class="font-mono text-[11px]">{selectedNode.dtype}</dd>
323
+ </div>
324
+ {/if}
325
+
326
+ {#if selectedNode.fillValue != null}
327
+ <div>
328
+ <dt class="text-muted-foreground">{t('zarr.fillValue')}</dt>
329
+ <dd class="font-mono text-[11px]">
330
+ {typeof selectedNode.fillValue === 'string'
331
+ ? selectedNode.fillValue
332
+ : JSON.stringify(selectedNode.fillValue)}
333
+ </dd>
334
+ </div>
335
+ {/if}
336
+
337
+ {#if selectedNode.chunks && selectedNode.chunks.length > 0}
338
+ <div>
339
+ <dt class="text-muted-foreground">{t('zarr.chunks')}</dt>
340
+ <dd class="font-mono text-[11px]">[{selectedNode.chunks.join(', ')}]</dd>
341
+ </div>
342
+
343
+ {@const chunkCount = computeChunkCount(selectedNode.shape, selectedNode.chunks)}
344
+ {#if chunkCount}
345
+ <div>
346
+ <dt class="text-muted-foreground">{t('zarr.chunkCount')}</dt>
347
+ <dd class="font-mono text-[11px]">{chunkCount}</dd>
154
348
  </div>
155
349
  {/if}
156
350
 
157
- {#if variables.length > 0}
158
- <div class="border-b border-zinc-200 px-3 py-2 dark:border-zinc-800">
159
- <h3 class="text-xs font-medium text-zinc-400">Data Variables ({variables.length})</h3>
351
+ {@const chunkSize = computeChunkSize(selectedNode.chunks, selectedNode.dtype)}
352
+ {#if chunkSize}
353
+ <div>
354
+ <dt class="text-muted-foreground">{t('zarr.chunkSize')}</dt>
355
+ <dd class="font-mono text-[11px]">{chunkSize}</dd>
160
356
  </div>
161
- {#each variables as v}
162
- <button
163
- class="flex w-full items-center gap-2 px-3 py-1.5 text-start text-xs hover:bg-zinc-100 dark:hover:bg-zinc-800"
164
- class:bg-blue-50={selectedNode?.name === v.name}
165
- class:dark:bg-blue-950={selectedNode?.name === v.name}
166
- onclick={() => (selectedNode = v)}
167
- >
168
- <span class="font-medium text-zinc-700 dark:text-zinc-300">{v.name}</span>
169
- <span class="ms-auto text-zinc-400">{v.dtype}</span>
170
- </button>
171
- {/each}
172
357
  {/if}
358
+ {/if}
173
359
 
174
- {#if coordVars.length > 0}
175
- <div class="border-b border-zinc-200 px-3 py-2 dark:border-zinc-800">
176
- <h3 class="text-xs font-medium text-zinc-400">Coordinates ({coordVars.length})</h3>
177
- </div>
178
- {#each coordVars as v}
360
+ {#if computeUncompressed(selectedNode.shape, selectedNode.dtype)}
361
+ <div>
362
+ <dt class="text-muted-foreground">{t('zarr.uncompressed')}</dt>
363
+ <dd class="font-mono text-[11px]">
364
+ {computeUncompressed(selectedNode.shape, selectedNode.dtype)}
365
+ </dd>
366
+ </div>
367
+ {/if}
368
+
369
+ {#if formatCodecs(selectedNode)}
370
+ <div>
371
+ <dt class="text-muted-foreground">{t('zarr.codecs')}</dt>
372
+ <dd class="font-mono text-[11px]">{formatCodecs(selectedNode)}</dd>
373
+ </div>
374
+ {/if}
375
+
376
+ {#if formatChunkKeys(selectedNode)}
377
+ <div>
378
+ <dt class="text-muted-foreground">{t('zarr.chunkKeys')}</dt>
379
+ <dd class="font-mono text-[11px]">{formatChunkKeys(selectedNode)}</dd>
380
+ </div>
381
+ {/if}
382
+
383
+ {#if selectedNode.kind === 'group'}
384
+ <div>
385
+ <dt class="text-muted-foreground">{t('zarr.children')}</dt>
386
+ <dd class="font-mono text-[11px]">{selectedNode.children.length}</dd>
387
+ </div>
388
+ {/if}
389
+
390
+ {#if Object.keys(selectedNode.attributes).length > 0}
391
+ <div>
392
+ <dt class="text-muted-foreground">{t('zarr.attributes')}</dt>
393
+ <dd>
394
+ <div
395
+ class="mt-1 rounded border border-zinc-200 bg-zinc-100 p-2 dark:border-zinc-700 dark:bg-zinc-800"
396
+ >
397
+ {#each Object.entries(selectedNode.attributes) as [key, value]}
398
+ <div class="flex gap-2 py-0.5">
399
+ <span class="shrink-0 font-medium text-muted-foreground">{key}:</span>
400
+ <span class="break-all text-zinc-700 dark:text-zinc-300">
401
+ {typeof value === 'string' ? value : JSON.stringify(value)}
402
+ </span>
403
+ </div>
404
+ {/each}
405
+ </div>
406
+ </dd>
407
+ </div>
408
+ {/if}
409
+ </dl>
410
+ </div>
411
+ {:else}
412
+ <div class="flex flex-1 items-center justify-center text-xs text-muted-foreground">
413
+ {t('zarr.selectNode')}
414
+ </div>
415
+ {/if}
416
+ {/snippet}
417
+
418
+ <div class="flex h-full flex-col">
419
+ <!-- Header bar -->
420
+ <div class="shrink-0 border-b border-zinc-200 px-3 py-2 sm:px-4 dark:border-zinc-800">
421
+ <div class="flex items-center gap-1.5 sm:gap-2">
422
+ <span
423
+ class="max-w-[140px] truncate text-sm font-medium text-zinc-700 sm:max-w-none dark:text-zinc-300"
424
+ >{tab.name}</span
425
+ >
426
+ <Badge
427
+ variant="secondary"
428
+ class="bg-purple-100 text-purple-600 dark:bg-purple-900 dark:text-purple-300"
429
+ >
430
+ {hierarchy?.zarrVersion ? `Zarr v${hierarchy.zarrVersion}` : t('zarr.badge')}
431
+ </Badge>
432
+
433
+ {#if hierarchy}
434
+ <span class="hidden text-xs text-muted-foreground sm:inline">
435
+ {hierarchy.totalNodes} {t('zarr.nodes')}
436
+ </span>
437
+ {/if}
438
+
439
+ <div class="ms-auto flex items-center gap-1">
440
+ <Button
441
+ variant={viewMode === 'inspect' ? 'secondary' : 'ghost'}
442
+ size="sm"
443
+ class="h-7 px-2 text-xs"
444
+ onclick={() => setViewMode('inspect')}
445
+ >
446
+ {t('zarr.inspect')}
447
+ </Button>
448
+ {#if hasMapVars}
449
+ <Button
450
+ variant={viewMode === 'map' ? 'secondary' : 'ghost'}
451
+ size="sm"
452
+ class="h-7 px-2 text-xs"
453
+ onclick={() => setViewMode('map')}
454
+ >
455
+ {t('zarr.map')}
456
+ </Button>
457
+ {/if}
458
+ </div>
459
+ </div>
460
+ </div>
461
+
462
+ <!-- Content -->
463
+ {#if loading}
464
+ <div class="flex flex-1 items-center justify-center">
465
+ <p class="text-sm text-zinc-400">{t('zarr.loading')}</p>
466
+ </div>
467
+ {:else if error}
468
+ <div class="flex flex-1 items-center justify-center">
469
+ <p class="max-w-md text-center text-sm text-red-400">{error}</p>
470
+ </div>
471
+ {:else if viewMode === 'map' && hasMapVars}
472
+ {#key viewMode}
473
+ {#await import('./ZarrMapViewer.svelte') then ZarrMapViewer}
474
+ <ZarrMapViewer.default
475
+ {tab}
476
+ variables={mapArrays}
477
+ coords={coordArrays}
478
+ spatialRefAttrs={hierarchy?.spatialRefAttrs ?? null}
479
+ zarrVersion={hierarchy?.zarrVersion}
480
+ />
481
+ {/await}
482
+ {/key}
483
+ {:else if hierarchy}
484
+ <!-- Inspect mode (tree + detail panel) -->
485
+ <ResizablePaneGroup direction="horizontal" class="min-h-0 flex-1">
486
+ <!-- Left: Tree view -->
487
+ <ResizablePane defaultSize={40} minSize={20}>
488
+ <div class="flex h-full flex-col">
489
+ <div
490
+ class="shrink-0 border-b border-zinc-200 px-3 py-1.5 text-[11px] font-medium uppercase tracking-wider text-muted-foreground dark:border-zinc-800"
491
+ >
492
+ {t('zarr.contents')}
493
+ <span class="ms-1 normal-case tracking-normal"
494
+ >({hierarchy.totalNodes})</span
495
+ >
496
+ </div>
497
+ <div class="flex-1 overflow-auto">
498
+ {#if hasStoreAttrs}
179
499
  <button
180
- class="flex w-full items-center gap-2 px-3 py-1.5 text-start text-xs hover:bg-zinc-100 dark:hover:bg-zinc-800"
181
- class:bg-blue-50={selectedNode?.name === v.name}
182
- class:dark:bg-blue-950={selectedNode?.name === v.name}
183
- onclick={() => (selectedNode = v)}
500
+ class="flex w-full items-center gap-2 border-b border-zinc-100 px-3 py-1 text-xs hover:bg-zinc-100 dark:border-zinc-800/50 dark:hover:bg-zinc-800/50"
501
+ class:bg-blue-50={showingStoreAttrs}
502
+ class:dark:bg-blue-950={showingStoreAttrs}
503
+ onclick={selectStoreAttrs}
184
504
  >
185
- <span class="text-zinc-500 dark:text-zinc-400">{v.name}</span>
186
- <span class="ms-auto text-zinc-400">{v.dtype}</span>
505
+ <span class="size-4 shrink-0"></span>
506
+ <svg
507
+ class="size-3.5 shrink-0 text-zinc-400"
508
+ viewBox="0 0 16 16"
509
+ fill="currentColor"
510
+ >
511
+ <path
512
+ fill-rule="evenodd"
513
+ d="M8 1a3.5 3.5 0 0 0-3.5 3.5V7H3a2 2 0 0 0-2 2v4a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-1.5V4.5A3.5 3.5 0 0 0 8 1Zm2 6V4.5a2 2 0 1 0-4 0V7h4Z"
514
+ clip-rule="evenodd"
515
+ />
516
+ </svg>
517
+ <span class="truncate font-medium text-muted-foreground">
518
+ {t('zarr.storeAttributes')}
519
+ </span>
187
520
  </button>
188
- {/each}
189
- {/if}
521
+ {/if}
522
+ {@render treeNode(hierarchy.root, 0)}
523
+ </div>
190
524
  </div>
525
+ </ResizablePane>
191
526
 
192
- <!-- Detail panel -->
193
- <div class="flex-1 overflow-auto p-4">
194
- {#if selectedNode}
195
- <h2 class="mb-3 text-sm font-semibold text-zinc-700 dark:text-zinc-200">
196
- {selectedNode.name}
197
- </h2>
198
- <dl class="space-y-2 text-xs">
199
- <dt class="font-medium text-zinc-500 dark:text-zinc-400">Shape</dt>
200
- <dd class="font-mono text-zinc-700 dark:text-zinc-300">{formatShape(selectedNode.shape)}</dd>
201
-
202
- {#if selectedNode.dims.length > 0}
203
- <dt class="font-medium text-zinc-500 dark:text-zinc-400">Dimensions</dt>
204
- <dd class="font-mono text-zinc-700 dark:text-zinc-300">
205
- ({selectedNode.dims.join(', ')})
206
- </dd>
207
- {/if}
208
-
209
- <dt class="font-medium text-zinc-500 dark:text-zinc-400">Data Type</dt>
210
- <dd class="font-mono text-zinc-700 dark:text-zinc-300">{selectedNode.dtype}</dd>
211
-
212
- {#if selectedNode.chunks.length > 0}
213
- <dt class="font-medium text-zinc-500 dark:text-zinc-400">Chunks</dt>
214
- <dd class="font-mono text-zinc-700 dark:text-zinc-300">[{selectedNode.chunks.join(', ')}]</dd>
215
- {/if}
216
-
217
- {#if Object.keys(selectedNode.attributes).length > 0}
218
- <dt class="mt-3 font-medium text-zinc-500 dark:text-zinc-400">Attributes</dt>
219
- <dd>
220
- <div class="mt-1 rounded border border-zinc-200 bg-zinc-100 p-2 dark:border-zinc-700 dark:bg-zinc-800">
221
- {#each Object.entries(selectedNode.attributes) as [key, value]}
222
- <div class="flex gap-2 py-0.5">
223
- <span class="shrink-0 font-medium text-zinc-500 dark:text-zinc-400">{key}:</span>
224
- <span class="break-all text-zinc-700 dark:text-zinc-300">
225
- {typeof value === 'string' ? value : JSON.stringify(value)}
226
- </span>
227
- </div>
228
- {/each}
229
- </div>
230
- </dd>
231
- {/if}
232
- </dl>
233
- {:else if Object.keys(storeAttrs).length > 0}
234
- <h2 class="mb-3 text-sm font-semibold text-zinc-700 dark:text-zinc-200">
235
- Store Attributes
236
- </h2>
237
- <div class="rounded border border-zinc-200 bg-zinc-100 p-2 text-xs dark:border-zinc-700 dark:bg-zinc-800">
238
- {#each Object.entries(storeAttrs) as [key, value]}
239
- <div class="flex gap-2 py-0.5">
240
- <span class="shrink-0 font-medium text-zinc-500 dark:text-zinc-400">{key}:</span>
241
- <span class="break-all text-zinc-700 dark:text-zinc-300">
242
- {typeof value === 'string' ? value : JSON.stringify(value)}
243
- </span>
244
- </div>
245
- {/each}
246
- </div>
247
- {:else}
248
- <div class="flex h-full items-center justify-center">
249
- <p class="text-sm text-zinc-400">Select a variable from the list</p>
250
- </div>
251
- {/if}
527
+ <ResizableHandle />
528
+
529
+ <!-- Right: Detail panel -->
530
+ <ResizablePane defaultSize={60} minSize={30}>
531
+ <div class="flex h-full flex-col">
532
+ {@render nodeDetails()}
252
533
  </div>
253
- </div>
254
- {/if}
255
- </div>
534
+ </ResizablePane>
535
+ </ResizablePaneGroup>
536
+ {/if}
256
537
  </div>