@walkthru-earth/objex 1.2.1 → 1.3.1
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.
- package/README.md +6 -3
- package/dist/components/layout/ConnectionDialog.svelte +35 -3
- package/dist/components/layout/Sidebar.svelte +1 -2
- package/dist/components/viewers/CodeViewer.svelte +51 -14
- package/dist/components/viewers/CodeViewer.svelte.d.ts +11 -1
- package/dist/components/viewers/CogControls.svelte +151 -22
- package/dist/components/viewers/CogControls.svelte.d.ts +5 -1
- package/dist/components/viewers/CogViewer.svelte +75 -8
- package/dist/components/viewers/MultiCogViewer.svelte +416 -0
- package/dist/components/viewers/MultiCogViewer.svelte.d.ts +9 -0
- package/dist/components/viewers/StacMapViewer.svelte +19 -5
- package/dist/components/viewers/StacMapViewer.svelte.d.ts +1 -0
- package/dist/components/viewers/StacMosaicViewer.svelte +785 -0
- package/dist/components/viewers/StacMosaicViewer.svelte.d.ts +9 -0
- package/dist/components/viewers/StacTabViewer.svelte +254 -0
- package/dist/components/viewers/StacTabViewer.svelte.d.ts +13 -0
- package/dist/components/viewers/ViewerRouter.svelte +155 -2
- package/dist/components/viewers/ViewerRouter.svelte.d.ts +1 -1
- package/dist/components/viewers/ZarrMapViewer.svelte +143 -4
- package/dist/components/viewers/ZarrMapViewer.svelte.d.ts +8 -2
- package/dist/components/viewers/ZarrViewer.svelte +1 -0
- package/dist/i18n/ar.js +27 -0
- package/dist/i18n/en.js +27 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +2 -0
- package/dist/query/stac-geoparquet.d.ts +31 -0
- package/dist/query/stac-geoparquet.js +136 -0
- package/dist/stores/connections.svelte.d.ts +38 -23
- package/dist/stores/connections.svelte.js +105 -114
- package/dist/utils/cog-pure.d.ts +25 -0
- package/dist/utils/cog-pure.js +35 -0
- package/dist/utils/cog.d.ts +88 -43
- package/dist/utils/cog.js +192 -152
- package/dist/utils/colormap-sprite.d.ts +39 -0
- package/dist/utils/colormap-sprite.js +77 -0
- package/dist/utils/connection-identity.d.ts +51 -0
- package/dist/utils/connection-identity.js +97 -0
- package/dist/utils/host-detection.js +48 -302
- package/dist/utils/parquet-metadata.d.ts +7 -1
- package/dist/utils/parquet-metadata.js +35 -1
- package/dist/utils/stac-geoparquet.d.ts +90 -0
- package/dist/utils/stac-geoparquet.js +223 -0
- package/dist/utils/stac-hydrate.d.ts +38 -0
- package/dist/utils/stac-hydrate.js +243 -0
- package/dist/utils/stac.d.ts +136 -0
- package/dist/utils/stac.js +176 -0
- package/dist/utils/storage-url.d.ts +26 -0
- package/dist/utils/storage-url.js +164 -28
- package/dist/utils/zarr.d.ts +34 -0
- package/dist/utils/zarr.js +94 -0
- package/package.json +14 -13
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Tab } from '../../types.js';
|
|
2
|
+
import { type StacRoutableKind } from '../../utils/stac.js';
|
|
3
|
+
type $$ComponentProps = {
|
|
4
|
+
tab: Tab;
|
|
5
|
+
classified?: StacRoutableKind;
|
|
6
|
+
};
|
|
7
|
+
declare const StacMosaicViewer: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
8
|
+
type StacMosaicViewer = ReturnType<typeof StacMosaicViewer>;
|
|
9
|
+
export default StacMosaicViewer;
|
|
@@ -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
|
|
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,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
|
|