@walkthru-earth/objex 1.2.1 → 1.3.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 (49) hide show
  1. package/README.md +6 -3
  2. package/dist/components/layout/ConnectionDialog.svelte +35 -3
  3. package/dist/components/layout/Sidebar.svelte +1 -2
  4. package/dist/components/viewers/CodeViewer.svelte +51 -14
  5. package/dist/components/viewers/CodeViewer.svelte.d.ts +11 -1
  6. package/dist/components/viewers/CogControls.svelte +151 -22
  7. package/dist/components/viewers/CogControls.svelte.d.ts +5 -1
  8. package/dist/components/viewers/CogViewer.svelte +24 -7
  9. package/dist/components/viewers/MultiCogViewer.svelte +416 -0
  10. package/dist/components/viewers/MultiCogViewer.svelte.d.ts +9 -0
  11. package/dist/components/viewers/StacMapViewer.svelte +11 -5
  12. package/dist/components/viewers/StacMapViewer.svelte.d.ts +1 -0
  13. package/dist/components/viewers/StacMosaicViewer.svelte +699 -0
  14. package/dist/components/viewers/StacMosaicViewer.svelte.d.ts +9 -0
  15. package/dist/components/viewers/StacTabViewer.svelte +254 -0
  16. package/dist/components/viewers/StacTabViewer.svelte.d.ts +13 -0
  17. package/dist/components/viewers/ViewerRouter.svelte +155 -2
  18. package/dist/components/viewers/ViewerRouter.svelte.d.ts +1 -1
  19. package/dist/components/viewers/ZarrMapViewer.svelte +143 -4
  20. package/dist/components/viewers/ZarrMapViewer.svelte.d.ts +8 -2
  21. package/dist/components/viewers/ZarrViewer.svelte +1 -0
  22. package/dist/i18n/ar.js +27 -0
  23. package/dist/i18n/en.js +27 -0
  24. package/dist/index.d.ts +4 -0
  25. package/dist/index.js +2 -0
  26. package/dist/query/stac-geoparquet.d.ts +31 -0
  27. package/dist/query/stac-geoparquet.js +136 -0
  28. package/dist/stores/connections.svelte.d.ts +38 -23
  29. package/dist/stores/connections.svelte.js +105 -114
  30. package/dist/utils/cog.d.ts +80 -18
  31. package/dist/utils/cog.js +187 -125
  32. package/dist/utils/colormap-sprite.d.ts +39 -0
  33. package/dist/utils/colormap-sprite.js +77 -0
  34. package/dist/utils/connection-identity.d.ts +51 -0
  35. package/dist/utils/connection-identity.js +97 -0
  36. package/dist/utils/host-detection.js +48 -302
  37. package/dist/utils/parquet-metadata.d.ts +7 -1
  38. package/dist/utils/parquet-metadata.js +35 -1
  39. package/dist/utils/stac-geoparquet.d.ts +90 -0
  40. package/dist/utils/stac-geoparquet.js +223 -0
  41. package/dist/utils/stac-hydrate.d.ts +38 -0
  42. package/dist/utils/stac-hydrate.js +243 -0
  43. package/dist/utils/stac.d.ts +136 -0
  44. package/dist/utils/stac.js +176 -0
  45. package/dist/utils/storage-url.d.ts +26 -0
  46. package/dist/utils/storage-url.js +164 -28
  47. package/dist/utils/zarr.d.ts +34 -0
  48. package/dist/utils/zarr.js +94 -0
  49. package/package.json +14 -13
@@ -0,0 +1,254 @@
1
+ <script lang="ts">
2
+ import CodeIcon from '@lucide/svelte/icons/file-code';
3
+ import GlobeIcon from '@lucide/svelte/icons/globe';
4
+ import LayersIcon from '@lucide/svelte/icons/layers';
5
+ import MapIcon from '@lucide/svelte/icons/map';
6
+ import { t } from '../../i18n/index.svelte.js';
7
+ import { connectionStore } from '../../stores/connections.svelte.js';
8
+ import type { Tab } from '../../types.js';
9
+ import type { StacRoutableKind } from '../../utils/stac.js';
10
+ import { canStreamDirectly } from '../../utils/url.js';
11
+ import { getUrlView, updateUrlView } from '../../utils/url-state.js';
12
+ import { Badge } from '../ui/badge/index.js';
13
+ import { Button } from '../ui/button/index.js';
14
+ import * as Tooltip from '../ui/tooltip/index.js';
15
+ import CodeViewer from './CodeViewer.svelte';
16
+ import StacMapViewer from './StacMapViewer.svelte';
17
+ import TableViewer from './TableViewer.svelte';
18
+
19
+ type MapKind = 'mosaic' | 'multicog' | null;
20
+
21
+ interface Props {
22
+ tab: Tab;
23
+ /** Which map viewer to mount when the user switches to `#map`. */
24
+ mapKind: MapKind;
25
+ /** Pre-classified STAC payload, forwarded to map viewers to skip re-parsing. */
26
+ classified?: StacRoutableKind;
27
+ }
28
+
29
+ let { tab, mapKind, classified }: Props = $props();
30
+
31
+ type ViewMode = 'map' | 'stac-map' | 'stac-browser' | 'code';
32
+
33
+ interface CodeActions {
34
+ toggleFormat: () => Promise<void>;
35
+ copyCode: () => Promise<void>;
36
+ canFormat: boolean;
37
+ formatted: boolean;
38
+ copied: boolean;
39
+ }
40
+
41
+ // Cross-origin STAC iframes (Radiant Earth stac-browser, DevSeed stac-map)
42
+ // crawl sibling items with their own fetch client and have no access to our
43
+ // presigned URLs. On signed-s3 connections the top manifest still renders but
44
+ // every child link 403s — keep the buttons available and surface a warning
45
+ // tooltip so the user can still preview the root document and knows why
46
+ // crawling children fails.
47
+ const iframeCrawlReachable = $derived.by(() => {
48
+ if (tab.source === 'url') return true;
49
+ if (!tab.connectionId) return true;
50
+ const conn = connectionStore.getById(tab.connectionId);
51
+ if (!conn) return true;
52
+ return canStreamDirectly(tab);
53
+ });
54
+
55
+ const isParquet = $derived.by(() => {
56
+ const ext = (tab.extension ?? '').toLowerCase();
57
+ return ext === 'parquet' || ext === 'geoparquet';
58
+ });
59
+
60
+ const formatBadge = $derived(isParquet ? 'Parquet' : 'JSON');
61
+
62
+ const stacBadgeKey = $derived.by(() => {
63
+ if (isParquet) return 'code.stacGeoparquet';
64
+ const kind = classified?.kind;
65
+ if (kind === 'item') return 'code.stacItem';
66
+ if (kind === 'item-collection') return 'code.stacItem';
67
+ if (kind === 'collection') return 'code.stacCollection';
68
+ if (kind === 'catalog') return 'code.stacCatalog';
69
+ return null;
70
+ });
71
+
72
+ function initialView(): ViewMode {
73
+ const urlView = getUrlView();
74
+ if (urlView === 'map' && mapKind) return 'map';
75
+ if (urlView === 'stac-map') return 'stac-map';
76
+ if (urlView === 'stac-browser') return 'stac-browser';
77
+ if (urlView === 'code') return 'code';
78
+ if (mapKind) return 'map';
79
+ return 'stac-map';
80
+ }
81
+
82
+ let viewMode = $state<ViewMode>(initialView());
83
+ let wordWrap = $state(false);
84
+ let codeActions = $state<CodeActions | null>(null);
85
+
86
+ function setView(next: ViewMode) {
87
+ if (viewMode === next) return;
88
+ viewMode = next;
89
+ updateUrlView(next === 'map' ? 'map' : next);
90
+ }
91
+ </script>
92
+
93
+ <Tooltip.Provider>
94
+ <div class="flex h-full flex-col overflow-hidden">
95
+ {#key tab.id}
96
+ <div
97
+ 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"
98
+ >
99
+ <span
100
+ class="max-w-[120px] truncate text-sm font-medium text-zinc-700 sm:max-w-none dark:text-zinc-300"
101
+ >
102
+ {tab.name}
103
+ </span>
104
+ <Badge variant="secondary">{formatBadge}</Badge>
105
+ {#if stacBadgeKey}
106
+ <Badge
107
+ variant="outline"
108
+ class="hidden border-emerald-200 text-emerald-600 sm:inline-flex dark:border-emerald-800 dark:text-emerald-300"
109
+ >
110
+ {t(stacBadgeKey)}
111
+ </Badge>
112
+ {/if}
113
+
114
+ <div class="ms-auto flex items-center gap-1 sm:gap-2">
115
+ {#if mapKind}
116
+ <Button
117
+ size="sm"
118
+ variant={viewMode === 'map' ? 'default' : 'ghost'}
119
+ class="h-7 gap-1 px-2"
120
+ onclick={() => setView('map')}
121
+ >
122
+ <MapIcon class="size-3.5" />
123
+ {mapKind === 'multicog' ? t('stac.viewMultiCog') : t('stac.viewMosaic')}
124
+ </Button>
125
+ {/if}
126
+ {#if iframeCrawlReachable}
127
+ <Button
128
+ size="sm"
129
+ variant={viewMode === 'stac-map' ? 'default' : 'ghost'}
130
+ class="h-7 gap-1 px-2"
131
+ onclick={() => setView('stac-map')}
132
+ >
133
+ <LayersIcon class="size-3.5" />
134
+ {t('stac.viewStacMap')}
135
+ </Button>
136
+ {#if isParquet}
137
+ <Tooltip.Root>
138
+ <Tooltip.Trigger>
139
+ <Button
140
+ size="sm"
141
+ variant="ghost"
142
+ class="h-7 gap-1 px-2 opacity-50"
143
+ disabled
144
+ >
145
+ <GlobeIcon class="size-3.5" />
146
+ {t('stac.viewBrowser')}
147
+ </Button>
148
+ </Tooltip.Trigger>
149
+ <Tooltip.Content>{t('stac.stacBrowserJsonOnly')}</Tooltip.Content>
150
+ </Tooltip.Root>
151
+ {:else}
152
+ <Button
153
+ size="sm"
154
+ variant={viewMode === 'stac-browser' ? 'default' : 'ghost'}
155
+ class="h-7 gap-1 px-2"
156
+ onclick={() => setView('stac-browser')}
157
+ >
158
+ <GlobeIcon class="size-3.5" />
159
+ {t('stac.viewBrowser')}
160
+ </Button>
161
+ {/if}
162
+ {:else}
163
+ <Tooltip.Root>
164
+ <Tooltip.Trigger>
165
+ <Button
166
+ size="sm"
167
+ variant={viewMode === 'stac-map' ? 'default' : 'ghost'}
168
+ class="h-7 gap-1 px-2"
169
+ onclick={() => setView('stac-map')}
170
+ >
171
+ <LayersIcon class="size-3.5" />
172
+ {t('stac.viewStacMap')}
173
+ </Button>
174
+ </Tooltip.Trigger>
175
+ <Tooltip.Content>{t('stac.iframePrivateBucketWarning')}</Tooltip.Content>
176
+ </Tooltip.Root>
177
+ <Tooltip.Root>
178
+ <Tooltip.Trigger>
179
+ <Button
180
+ size="sm"
181
+ variant={viewMode === 'stac-browser' ? 'default' : 'ghost'}
182
+ class="h-7 gap-1 px-2"
183
+ onclick={() => setView('stac-browser')}
184
+ >
185
+ <GlobeIcon class="size-3.5" />
186
+ {t('stac.viewBrowser')}
187
+ </Button>
188
+ </Tooltip.Trigger>
189
+ <Tooltip.Content>{t('stac.iframePrivateBucketWarning')}</Tooltip.Content>
190
+ </Tooltip.Root>
191
+ {/if}
192
+ <Button
193
+ size="sm"
194
+ variant={viewMode === 'code' ? 'default' : 'ghost'}
195
+ class="h-7 gap-1 px-2"
196
+ onclick={() => setView('code')}
197
+ >
198
+ <CodeIcon class="size-3.5" />
199
+ {isParquet ? t('stac.viewTable') : t('stac.viewJson')}
200
+ </Button>
201
+
202
+ {#if viewMode === 'code' && !isParquet && codeActions}
203
+ {#if codeActions.canFormat}
204
+ <Button
205
+ variant="ghost"
206
+ size="sm"
207
+ class="h-7 px-2 text-xs"
208
+ onclick={() => codeActions?.toggleFormat()}
209
+ >
210
+ {codeActions.formatted ? t('code.raw') : t('code.format')}
211
+ </Button>
212
+ {/if}
213
+ <Button
214
+ variant="ghost"
215
+ size="sm"
216
+ class="h-7 px-2 text-xs"
217
+ onclick={() => (wordWrap = !wordWrap)}
218
+ >
219
+ {wordWrap ? t('code.noWrap') : t('code.wrap')}
220
+ </Button>
221
+ <Button
222
+ variant="ghost"
223
+ size="sm"
224
+ class="h-7 px-2 text-xs"
225
+ onclick={() => codeActions?.copyCode()}
226
+ >
227
+ {codeActions.copied ? t('code.copied') : t('code.copy')}
228
+ </Button>
229
+ {/if}
230
+ </div>
231
+ </div>
232
+
233
+ <div class="relative flex-1 overflow-hidden">
234
+ {#if viewMode === 'map' && mapKind === 'mosaic'}
235
+ {#await import('./StacMosaicViewer.svelte') then { default: StacMosaicViewer }}
236
+ <StacMosaicViewer {tab} {classified} />
237
+ {/await}
238
+ {:else if viewMode === 'map' && mapKind === 'multicog'}
239
+ {#await import('./MultiCogViewer.svelte') then { default: MultiCogViewer }}
240
+ <MultiCogViewer {tab} {classified} />
241
+ {/await}
242
+ {:else if viewMode === 'stac-map'}
243
+ <StacMapViewer {tab} variant="stac-map" />
244
+ {:else if viewMode === 'stac-browser'}
245
+ <StacMapViewer {tab} variant="stac-browser" />
246
+ {:else if isParquet}
247
+ <TableViewer {tab} />
248
+ {:else}
249
+ <CodeViewer {tab} nested bind:wordWrap bind:actions={codeActions} />
250
+ {/if}
251
+ </div>
252
+ {/key}
253
+ </div>
254
+ </Tooltip.Provider>
@@ -0,0 +1,13 @@
1
+ import type { Tab } from '../../types.js';
2
+ import type { StacRoutableKind } from '../../utils/stac.js';
3
+ type MapKind = 'mosaic' | 'multicog' | null;
4
+ interface Props {
5
+ tab: Tab;
6
+ /** Which map viewer to mount when the user switches to `#map`. */
7
+ mapKind: MapKind;
8
+ /** Pre-classified STAC payload, forwarded to map viewers to skip re-parsing. */
9
+ classified?: StacRoutableKind;
10
+ }
11
+ declare const StacTabViewer: import("svelte").Component<Props, {}, "">;
12
+ type StacTabViewer = ReturnType<typeof StacTabViewer>;
13
+ export default StacTabViewer;
@@ -1,19 +1,170 @@
1
1
  <script lang="ts">
2
2
  import { getViewerKind } from '../../file-icons/index.js';
3
- import type { Tab } from '../../types';
3
+ import { getAdapter } from '../../storage/index.js';
4
+ import type { Tab } from '../../types.js';
5
+ import { readParquetMetadata } from '../../utils/parquet-metadata.js';
6
+ import {
7
+ classifyStac,
8
+ detectMosaicCapable,
9
+ detectMultiCogCapable,
10
+ type StacRoutableKind
11
+ } from '../../utils/stac.js';
12
+ import { isStacGeoparquetSchema } from '../../utils/stac-geoparquet.js';
13
+ import { STAC_API_PATH_RE } from '../../utils/storage-url.js';
14
+ import { buildHttpsUrlAsync } from '../../utils/url.js';
4
15
  import CodeViewer from './CodeViewer.svelte';
5
16
  import ImageViewer from './ImageViewer.svelte';
6
17
  import MediaViewer from './MediaViewer.svelte';
7
18
  import RawViewer from './RawViewer.svelte';
19
+ import StacTabViewer from './StacTabViewer.svelte';
8
20
  import TableViewer from './TableViewer.svelte';
9
21
 
10
22
  let { tab }: { tab: Tab } = $props();
11
23
 
12
24
  const ext = $derived(tab?.extension ?? '');
13
25
  const viewerKind = $derived(getViewerKind(ext));
26
+
27
+ type StacRoute =
28
+ | { kind: 'pending' }
29
+ | { kind: 'none' }
30
+ | { kind: 'stac'; mapKind: 'mosaic' | 'multicog' | null; classified: StacRoutableKind };
31
+ const MAX_STAC_PEEK = 256 * 1024;
32
+
33
+ let stacRoute = $state<StacRoute>({ kind: 'none' });
34
+ let stacSignalCtrl: AbortController | null = null;
35
+
36
+ $effect(() => {
37
+ // Track the full tab identity so auto-migration (eager `url` tab → remote
38
+ // tab with a real connectionId) re-runs classification with the now-valid
39
+ // adapter. Without these reads the effect only depends on `ext`, and a
40
+ // stale 403 would leave the file stuck on the non-STAC CodeViewer path.
41
+ const tabId = tab.id;
42
+ const tabPath = tab.path;
43
+ const tabSource = tab.source;
44
+ const tabConn = tab.connectionId;
45
+ void tabId;
46
+ void tabPath;
47
+ void tabSource;
48
+ void tabConn;
49
+
50
+ const currentExt = ext.toLowerCase().replace(/^\./, '');
51
+ const isJsonExt = currentExt === 'json' || currentExt === 'geojson';
52
+ // STAC API endpoints return `application/geo+json` at extensionless paths
53
+ // like `/v1/collections/.../items/S2B_18TVK_20240928_0_L2A`, so we still
54
+ // peek when the basename has no dot.
55
+ const isExtensionless = !currentExt;
56
+ const viewerEligible = viewerKind === 'code' || viewerKind === 'raw';
57
+ let isStacPath = false;
58
+ if (isExtensionless) {
59
+ try {
60
+ isStacPath = STAC_API_PATH_RE.test(new URL(tab.path).pathname);
61
+ } catch {
62
+ isStacPath = false;
63
+ }
64
+ }
65
+ const isParquetExt = currentExt === 'parquet' || currentExt === 'geoparquet';
66
+ const shouldPeek = viewerEligible && (isJsonExt || (isExtensionless && isStacPath));
67
+ stacSignalCtrl?.abort();
68
+ if (!shouldPeek && !isParquetExt) {
69
+ stacRoute = { kind: 'none' };
70
+ return;
71
+ }
72
+ stacRoute = { kind: 'pending' };
73
+ const ctrl = new AbortController();
74
+ stacSignalCtrl = ctrl;
75
+ const detector = isParquetExt
76
+ ? detectStacGeoparquet(tab, ctrl.signal)
77
+ : detectStac(tab, ctrl.signal);
78
+ void detector.then((result) => {
79
+ if (ctrl.signal.aborted) return;
80
+ stacRoute = result;
81
+ });
82
+ return () => ctrl.abort();
83
+ });
84
+
85
+ async function detectStacGeoparquet(current: Tab, signal: AbortSignal): Promise<StacRoute> {
86
+ try {
87
+ const url = await buildHttpsUrlAsync(current);
88
+ if (signal.aborted) return { kind: 'none' };
89
+ const meta = await readParquetMetadata(url);
90
+ if (signal.aborted) return { kind: 'none' };
91
+ // Use top-level column names so struct parents (`assets`, `bbox`) are
92
+ // visible. `meta.schema` flattens structs away, which hides the very
93
+ // columns stac-geoparquet detection keys on.
94
+ const topLevel = meta.topLevelColumns.map((name) => ({ name }));
95
+ if (!isStacGeoparquetSchema(topLevel)) return { kind: 'none' };
96
+ return {
97
+ kind: 'stac',
98
+ mapKind: 'mosaic',
99
+ classified: { kind: 'item-collection', fc: { type: 'FeatureCollection', features: [] } }
100
+ };
101
+ } catch {
102
+ return { kind: 'none' };
103
+ }
104
+ }
105
+
106
+ async function detectStac(current: Tab, signal: AbortSignal): Promise<StacRoute> {
107
+ const adapter = getAdapter(current.source, current.connectionId);
108
+ const decoder = new TextDecoder('utf-8', { fatal: false });
109
+
110
+ // Peek the first 256 KB first; a small catalog/collection parses outright.
111
+ // STAC Items with detailed asset metadata + dense footprint coordinates
112
+ // frequently blow past that, so on a parse failure we fall back to the
113
+ // full file. Network errors (403, CORS) short-circuit to `none`.
114
+ // `classifyStac` already returns `{ kind: 'none' }` for any JSON that
115
+ // isn't a STAC Item/Collection/Catalog/ItemCollection — propagate that
116
+ // so plain JSON files don't route through StacTabViewer (which exposes
117
+ // the stac-map / STAC Browser buttons).
118
+ try {
119
+ const peek = await adapter.read(current.path, 0, MAX_STAC_PEEK, signal);
120
+ if (signal.aborted) return { kind: 'none' };
121
+ try {
122
+ const parsed = JSON.parse(decoder.decode(peek));
123
+ const classified = classifyStac(parsed);
124
+ if (classified.kind === 'none') return { kind: 'none' };
125
+ return { kind: 'stac', mapKind: pickMapKind(classified), classified };
126
+ } catch {
127
+ if (peek.byteLength < MAX_STAC_PEEK) return { kind: 'none' };
128
+ }
129
+ } catch {
130
+ return { kind: 'none' };
131
+ }
132
+
133
+ try {
134
+ const full = await adapter.read(current.path, undefined, undefined, signal);
135
+ if (signal.aborted) return { kind: 'none' };
136
+ const parsed = JSON.parse(decoder.decode(full));
137
+ const classified = classifyStac(parsed);
138
+ if (classified.kind === 'none') return { kind: 'none' };
139
+ return { kind: 'stac', mapKind: pickMapKind(classified), classified };
140
+ } catch {
141
+ return { kind: 'none' };
142
+ }
143
+ }
144
+
145
+ function pickMapKind(classified: StacRoutableKind): 'mosaic' | 'multicog' | null {
146
+ switch (classified.kind) {
147
+ case 'item':
148
+ if (detectMultiCogCapable(classified.item)) return 'multicog';
149
+ if (detectMosaicCapable(classified.item)) return 'mosaic';
150
+ return null;
151
+ case 'item-collection': {
152
+ const first = classified.fc.features[0];
153
+ if (first && detectMultiCogCapable(first)) return 'multicog';
154
+ return 'mosaic';
155
+ }
156
+ case 'collection':
157
+ case 'catalog':
158
+ return 'mosaic';
159
+ case 'none':
160
+ return null;
161
+ }
162
+ }
14
163
  </script>
15
164
 
16
- {#if viewerKind === 'table'}
165
+ {#if stacRoute.kind === 'stac' && viewerKind === 'table'}
166
+ <StacTabViewer {tab} mapKind={stacRoute.mapKind} classified={stacRoute.classified} />
167
+ {:else if viewerKind === 'table'}
17
168
  <TableViewer {tab} />
18
169
  {:else if viewerKind === 'image'}
19
170
  <ImageViewer {tab} />
@@ -23,6 +174,8 @@ const viewerKind = $derived(getViewerKind(ext));
23
174
  {#await import('./MarkdownViewer.svelte') then { default: MarkdownViewer }}
24
175
  <MarkdownViewer {tab} />
25
176
  {/await}
177
+ {:else if stacRoute.kind === 'stac' && (viewerKind === 'code' || viewerKind === 'raw')}
178
+ <StacTabViewer {tab} mapKind={stacRoute.mapKind} classified={stacRoute.classified} />
26
179
  {:else if viewerKind === 'code'}
27
180
  <CodeViewer {tab} />
28
181
  {:else if viewerKind === 'cog'}
@@ -1,4 +1,4 @@
1
- import type { Tab } from '../../types';
1
+ import type { Tab } from '../../types.js';
2
2
  type $$ComponentProps = {
3
3
  tab: Tab;
4
4
  };
@@ -1,15 +1,20 @@
1
1
  <script lang="ts">
2
+ import { MapboxOverlay } from '@deck.gl/mapbox';
2
3
  import type maplibregl from 'maplibre-gl';
3
4
  import maplibreModule from 'maplibre-gl';
4
5
  import { onDestroy, untrack } from 'svelte';
5
6
  import { t } from '../../i18n/index.svelte.js';
6
7
  import { tabResources } from '../../stores/tab-resources.svelte.js';
7
- import type { Tab } from '../../types';
8
+ import type { Tab } from '../../types.js';
9
+ import { createEpsgResolver } from '../../utils/cog.js';
8
10
  import { buildHttpsUrlAsync } from '../../utils/url.js';
9
11
  import {
12
+ detectGeoZarr,
10
13
  ensureCodecsRegistered,
11
14
  extractZarrStoreUrl,
15
+ type GeoZarrInfo,
12
16
  inferDims,
17
+ type ZarrHierarchy,
13
18
  type ZarrNode
14
19
  } from '../../utils/zarr.js';
15
20
  import MapContainer from './map/MapContainer.svelte';
@@ -40,21 +45,40 @@ let {
40
45
  variables,
41
46
  coords = [],
42
47
  spatialRefAttrs,
43
- zarrVersion = null
48
+ zarrVersion = null,
49
+ hierarchy = null
44
50
  }: {
45
51
  tab: Tab;
46
52
  variables: ZarrNode[];
47
53
  coords?: ZarrNode[];
48
54
  spatialRefAttrs: Record<string, any> | null;
49
55
  zarrVersion?: number | null;
56
+ /**
57
+ * Full pre-loaded hierarchy. When present, `detectGeoZarr` can short-circuit
58
+ * to the `@developmentseed/deck.gl-zarr` path for GeoZarr-valid stores.
59
+ * Non-GeoZarr stores fall through to `@carbonplan/zarr-layer`.
60
+ */
61
+ hierarchy?: ZarrHierarchy | null;
50
62
  } = $props();
51
63
 
64
+ // GeoZarr detection runs once per hierarchy so the branch decision is stable
65
+ // across selector-slider tweaks. Returns null for non-GeoZarr stores, which
66
+ // sends everything through the existing carbonplan path.
67
+ const geoZarrInfo = $derived<GeoZarrInfo | null>(hierarchy ? detectGeoZarr(hierarchy) : null);
68
+
69
+ // MapboxOverlay holder for the deck.gl-zarr path. Separate from zarrLayer so
70
+ // the two paths can be cleaned up independently.
71
+ let dsZarrOverlay: MapboxOverlay | null = null;
72
+ const dsZarrEpsg = createEpsgResolver();
73
+
52
74
  let loading = $state(true);
53
75
  let error = $state<string | null>(null);
54
76
  let selectedVar = $state('');
55
77
  let zarrLayer: any = null;
56
78
  let mapRef: maplibregl.Map | null = null;
57
79
  let inspectPopup: maplibregl.Popup | null = null;
80
+ let loadGen = 0;
81
+ let addAbort = new AbortController();
58
82
 
59
83
  // Extract proj4 from spatial_ref if available
60
84
  const proj4String = $derived(extractProj4(spatialRefAttrs));
@@ -359,20 +383,39 @@ async function onMapReady(map: maplibregl.Map) {
359
383
  }
360
384
 
361
385
  async function addZarrLayer(map: maplibregl.Map) {
386
+ addAbort.abort();
387
+ addAbort = new AbortController();
388
+ const signal = addAbort.signal;
389
+ const gen = ++loadGen;
362
390
  loading = true;
363
391
  error = null;
364
392
 
365
393
  try {
366
- // Remove existing layer
367
394
  if (zarrLayer && map.getLayer(zarrLayer.id)) {
368
395
  map.removeLayer(zarrLayer.id);
369
396
  }
397
+ if (dsZarrOverlay) {
398
+ try {
399
+ map.removeControl(dsZarrOverlay as unknown as maplibregl.IControl);
400
+ } catch {
401
+ /* already removed */
402
+ }
403
+ dsZarrOverlay = null;
404
+ }
405
+
406
+ if (geoZarrInfo) {
407
+ const used = await tryAddGeoZarrLayer(map, gen, signal);
408
+ if (gen !== loadGen || signal.aborted) return;
409
+ if (used) return;
410
+ }
370
411
 
371
- // Ensure numcodecs codecs (shuffle, zlib, etc.) are registered before zarr-layer uses zarrita
372
412
  await ensureCodecsRegistered();
413
+ if (gen !== loadGen || signal.aborted) return;
373
414
  const { ZarrLayer } = await import('@carbonplan/zarr-layer');
415
+ if (gen !== loadGen || signal.aborted) return;
374
416
 
375
417
  const storeUrl = await buildStoreUrl();
418
+ if (gen !== loadGen || signal.aborted) return;
376
419
  const selector = buildSelector();
377
420
 
378
421
  const opts: any = {
@@ -457,6 +500,97 @@ async function buildStoreUrl(): Promise<string> {
457
500
  return extractZarrStoreUrl(rawUrl) ?? rawUrl;
458
501
  }
459
502
 
503
+ /**
504
+ * Attempt to render via `@developmentseed/deck.gl-zarr`. Returns true on
505
+ * success (carbonplan fallback is skipped), false on any setup error so the
506
+ * caller can fall through to the legacy path. Errors thrown inside the layer
507
+ * after setup propagate through the overlay's `onError`.
508
+ */
509
+ async function tryAddGeoZarrLayer(
510
+ map: maplibregl.Map,
511
+ gen: number,
512
+ signal: AbortSignal
513
+ ): Promise<boolean> {
514
+ if (!geoZarrInfo) return false;
515
+ try {
516
+ const zarrita = await import('zarrita');
517
+ if (gen !== loadGen || signal.aborted) return false;
518
+ const { ZarrLayer } = await import('@developmentseed/deck.gl-zarr');
519
+ if (gen !== loadGen || signal.aborted) return false;
520
+ const storeUrl = await buildStoreUrl();
521
+ if (gen !== loadGen || signal.aborted) return false;
522
+ const store = new zarrita.FetchStore(storeUrl);
523
+ const group = await zarrita.open(store, { kind: 'group' });
524
+ if (gen !== loadGen || signal.aborted) return false;
525
+
526
+ const zarrInfoSnapshot = $state.snapshot(geoZarrInfo) as GeoZarrInfo;
527
+ const layer = new ZarrLayer({
528
+ id: `geozarr-${tab.id}`,
529
+ source: group,
530
+ variable: zarrInfoSnapshot.variantPath || undefined,
531
+ selection: {},
532
+ epsgResolver: dsZarrEpsg,
533
+ getTileData: async (arr, options) => {
534
+ const chunk = await zarrita.get(arr, options.sliceSpec);
535
+ if (gen !== loadGen || signal.aborted) {
536
+ throw new DOMException('Aborted', 'AbortError');
537
+ }
538
+ const data = chunk.data as unknown as ArrayLike<number> & { length: number };
539
+ return {
540
+ width: options.width,
541
+ height: options.height,
542
+ data,
543
+ byteLength: data.length
544
+ };
545
+ },
546
+ renderTile: (data) => {
547
+ const raw = (data as unknown as { data: ArrayLike<number> & { length: number } }).data;
548
+ if (!raw) return { image: undefined } as never;
549
+ let clamped: Uint8ClampedArray;
550
+ const asTyped = raw as unknown as {
551
+ buffer?: ArrayBufferLike;
552
+ byteOffset?: number;
553
+ byteLength?: number;
554
+ };
555
+ if (
556
+ asTyped.buffer &&
557
+ typeof asTyped.byteOffset === 'number' &&
558
+ typeof asTyped.byteLength === 'number'
559
+ ) {
560
+ clamped = new Uint8ClampedArray(asTyped.buffer, asTyped.byteOffset, asTyped.byteLength);
561
+ } else {
562
+ clamped = new Uint8ClampedArray(raw as unknown as Uint8Array);
563
+ }
564
+ const img = new ImageData(
565
+ clamped as unknown as Uint8ClampedArray<ArrayBuffer>,
566
+ data.width,
567
+ data.height
568
+ );
569
+ return { image: img };
570
+ }
571
+ });
572
+
573
+ const overlay = new MapboxOverlay({
574
+ interleaved: false,
575
+ layers: [layer],
576
+ onError: (err) => {
577
+ error = err?.message || String(err);
578
+ loading = false;
579
+ }
580
+ });
581
+ dsZarrOverlay = overlay;
582
+ map.addControl(overlay as unknown as maplibregl.IControl);
583
+ loading = false;
584
+ return true;
585
+ } catch {
586
+ // Fall back to carbonplan path on any setup failure (e.g. the store
587
+ // looked like GeoZarr by shape but zarrita open failed, or the group
588
+ // attrs don't actually parse). Silent by design, the caller will mount
589
+ // carbonplan's ZarrLayer which surfaces its own errors.
590
+ return false;
591
+ }
592
+ }
593
+
460
594
  // Re-render when selector changes
461
595
  async function updateSelector() {
462
596
  if (!zarrLayer) return;
@@ -476,6 +610,7 @@ async function changeVariable() {
476
610
  }
477
611
 
478
612
  function cleanup() {
613
+ addAbort.abort();
479
614
  inspectPopup?.remove();
480
615
  inspectPopup = null;
481
616
  try {
@@ -483,10 +618,14 @@ function cleanup() {
483
618
  if (zarrLayer && mapRef?.getLayer('zarr-data')) {
484
619
  mapRef.removeLayer('zarr-data');
485
620
  }
621
+ if (mapRef && dsZarrOverlay) {
622
+ mapRef.removeControl(dsZarrOverlay as unknown as maplibregl.IControl);
623
+ }
486
624
  } catch {
487
625
  // map may already be destroyed
488
626
  }
489
627
  zarrLayer = null;
628
+ dsZarrOverlay = null;
490
629
  mapRef = null;
491
630
  }
492
631