@walkthru-earth/objex 1.2.0 → 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.
- package/README.md +6 -3
- package/dist/components/browser/FileTreeSidebar.svelte +1 -1
- package/dist/components/layout/ConnectionDialog.svelte +35 -3
- package/dist/components/layout/Sidebar.svelte +28 -2
- package/dist/components/viewers/ArchiveViewer.svelte +4 -4
- package/dist/components/viewers/CodeViewer.svelte +72 -19
- 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 +45 -10
- package/dist/components/viewers/CopcViewer.svelte +20 -2
- package/dist/components/viewers/FlatGeobufViewer.svelte +15 -9
- package/dist/components/viewers/MultiCogViewer.svelte +416 -0
- package/dist/components/viewers/MultiCogViewer.svelte.d.ts +9 -0
- package/dist/components/viewers/PmtilesViewer.svelte +2 -2
- package/dist/components/viewers/StacMapViewer.svelte +34 -12
- package/dist/components/viewers/StacMapViewer.svelte.d.ts +1 -0
- package/dist/components/viewers/StacMosaicViewer.svelte +699 -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/TableViewer.svelte +50 -21
- package/dist/components/viewers/ViewerRouter.svelte +155 -2
- package/dist/components/viewers/ViewerRouter.svelte.d.ts +1 -1
- package/dist/components/viewers/ZarrMapViewer.svelte +147 -8
- package/dist/components/viewers/ZarrMapViewer.svelte.d.ts +8 -2
- package/dist/components/viewers/ZarrViewer.svelte +3 -2
- package/dist/components/viewers/pmtiles/PmtilesMapView.svelte +0 -1
- package/dist/i18n/ar.js +28 -0
- package/dist/i18n/en.js +28 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +2 -0
- package/dist/query/index.d.ts +1 -1
- package/dist/query/index.js +1 -1
- package/dist/query/source.d.ts +12 -0
- package/dist/query/source.js +25 -8
- package/dist/query/stac-geoparquet.d.ts +31 -0
- package/dist/query/stac-geoparquet.js +136 -0
- package/dist/query/wasm.js +130 -23
- package/dist/storage/adapter.d.ts +9 -0
- package/dist/storage/adapter.js +13 -1
- package/dist/storage/browser-azure.d.ts +1 -1
- package/dist/storage/browser-azure.js +4 -0
- package/dist/storage/browser-cloud.d.ts +1 -1
- package/dist/storage/browser-cloud.js +7 -0
- package/dist/storage/presign.d.ts +13 -0
- package/dist/storage/presign.js +55 -0
- package/dist/storage/providers.d.ts +6 -0
- package/dist/storage/providers.js +13 -2
- package/dist/stores/browser.svelte.d.ts +2 -0
- package/dist/stores/browser.svelte.js +17 -1
- package/dist/stores/connections.svelte.d.ts +38 -23
- package/dist/stores/connections.svelte.js +105 -114
- package/dist/utils/cog.d.ts +80 -18
- package/dist/utils/cog.js +187 -125
- 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/url.d.ts +13 -0
- package/dist/utils/url.js +36 -0
- package/dist/utils/wkb.js +22 -8
- package/dist/utils/zarr.d.ts +34 -0
- package/dist/utils/zarr.js +94 -0
- 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;
|
|
@@ -11,7 +11,8 @@ import {
|
|
|
11
11
|
QueryCancelledError,
|
|
12
12
|
type QueryHandle,
|
|
13
13
|
type ResolvedTableSource,
|
|
14
|
-
resolveTableSource
|
|
14
|
+
resolveTableSource,
|
|
15
|
+
resolveTableSourceAsync
|
|
15
16
|
} from '../../query/index.js';
|
|
16
17
|
import { queryHistory } from '../../stores/query-history.svelte.js';
|
|
17
18
|
import { settings } from '../../stores/settings.svelte.js';
|
|
@@ -61,6 +62,8 @@ let viewMode = $state<'table' | 'map' | 'stac' | 'info'>(
|
|
|
61
62
|
);
|
|
62
63
|
let sqlQuery = $state('');
|
|
63
64
|
let customSql = $state('');
|
|
65
|
+
// Presigned URL for the source-cooperative parquet-table iframe (external fetcher).
|
|
66
|
+
let parquetIframeUrl = $state('');
|
|
64
67
|
let queryRunning = $state(false);
|
|
65
68
|
let executionTimeMs = $state(0);
|
|
66
69
|
|
|
@@ -98,8 +101,12 @@ const columnTypes = $derived(Object.fromEntries(schema.map((f) => [f.name, f.typ
|
|
|
98
101
|
// Columns for display — exclude internal __wkb helper
|
|
99
102
|
const displayColumns = $derived(columns.filter((c) => c !== '__wkb'));
|
|
100
103
|
|
|
101
|
-
|
|
102
|
-
|
|
104
|
+
let resolvedSource: ResolvedTableSource | null = null;
|
|
105
|
+
|
|
106
|
+
function buildDefaultSql(
|
|
107
|
+
offset = 0,
|
|
108
|
+
resolved: ResolvedTableSource = resolvedSource ?? resolveTableSource(tab)
|
|
109
|
+
): string {
|
|
103
110
|
const source = resolved.ref;
|
|
104
111
|
|
|
105
112
|
let sql: string;
|
|
@@ -204,6 +211,8 @@ $effect(() => {
|
|
|
204
211
|
geoCol = null;
|
|
205
212
|
knownGeomType = undefined;
|
|
206
213
|
metadataBounds = null;
|
|
214
|
+
resolvedSource = null;
|
|
215
|
+
parquetIframeUrl = '';
|
|
207
216
|
error = null;
|
|
208
217
|
});
|
|
209
218
|
return unregister;
|
|
@@ -268,6 +277,10 @@ async function forceCancel() {
|
|
|
268
277
|
|
|
269
278
|
async function loadTable() {
|
|
270
279
|
const thisGen = ++loadGeneration;
|
|
280
|
+
// Snapshot reactive values once. Reading `$derived` across awaits after the
|
|
281
|
+
// component's effect is torn down returns Svelte's destroyed-signal sentinel,
|
|
282
|
+
// which throws "can't convert symbol to string" in downstream template literals.
|
|
283
|
+
const cid = connId;
|
|
271
284
|
|
|
272
285
|
// Cancel in-flight query from a previous load to prevent duplicate concurrent queries
|
|
273
286
|
if (activeHandle) {
|
|
@@ -287,13 +300,19 @@ async function loadTable() {
|
|
|
287
300
|
loadStage = t('table.preparingQuery');
|
|
288
301
|
loadProgress = [];
|
|
289
302
|
|
|
290
|
-
|
|
291
|
-
const
|
|
292
|
-
sqlQuery =
|
|
293
|
-
customSql =
|
|
303
|
+
resolvedSource = null;
|
|
304
|
+
const eagerSql = buildDefaultSql(0);
|
|
305
|
+
sqlQuery = eagerSql;
|
|
306
|
+
customSql = eagerSql;
|
|
294
307
|
|
|
295
308
|
try {
|
|
296
|
-
const resolved: ResolvedTableSource =
|
|
309
|
+
const resolved: ResolvedTableSource = await resolveTableSourceAsync(tab);
|
|
310
|
+
if (thisGen !== loadGeneration) return;
|
|
311
|
+
resolvedSource = resolved;
|
|
312
|
+
const resolvedSql = buildDefaultSql(0, resolved);
|
|
313
|
+
sqlQuery = resolvedSql;
|
|
314
|
+
// Only overwrite the editor if the user hasn't edited it during the presign await.
|
|
315
|
+
if (customSql === eagerSql) customSql = resolvedSql;
|
|
297
316
|
const isFileSource = resolved.isFileSource;
|
|
298
317
|
const fileUrl = resolved.fileUrl ?? '';
|
|
299
318
|
const httpsUrl = isFileSource ? buildHttpsUrl(tab) : '';
|
|
@@ -301,6 +320,11 @@ async function loadTable() {
|
|
|
301
320
|
const isParquet = isFileSource && /\.parquet$/i.test(tab.path);
|
|
302
321
|
const streamable = isFileSource && canStreamDirectly(tab);
|
|
303
322
|
|
|
323
|
+
// Parquet-table iframe fetches from its own origin. `resolved.fileUrl`
|
|
324
|
+
// is already the presigned HTTPS URL for signed-s3 (or the public URL
|
|
325
|
+
// for anonymous / SAS), so reuse it instead of signing a second time.
|
|
326
|
+
if (isParquet) parquetIframeUrl = fileUrl;
|
|
327
|
+
|
|
304
328
|
// Start DuckDB boot immediately (runs in parallel with hyparquet)
|
|
305
329
|
loadStage = t('table.initEngine');
|
|
306
330
|
const enginePromise = getQueryEngine();
|
|
@@ -448,7 +472,7 @@ async function loadTable() {
|
|
|
448
472
|
// Disable conversion for this connection and fall back to BLOB handling.
|
|
449
473
|
if (metaFromHyparquet && isLegacyGeoParquet && geoCol) {
|
|
450
474
|
try {
|
|
451
|
-
await engine.query(
|
|
475
|
+
await engine.query(cid, 'SET enable_geoparquet_conversion = false');
|
|
452
476
|
geoColType = 'BLOB';
|
|
453
477
|
} catch {
|
|
454
478
|
// Setting failed — DuckDB may still handle it gracefully
|
|
@@ -460,7 +484,7 @@ async function loadTable() {
|
|
|
460
484
|
// reads GeoParquet as GEOMETRY('EPSG:...') with CRS embedded in the type.
|
|
461
485
|
if (metaFromHyparquet && geoCol && !isLegacyGeoParquet) {
|
|
462
486
|
try {
|
|
463
|
-
const duckSchema = await engine.getSchema(
|
|
487
|
+
const duckSchema = await engine.getSchema(cid, resolved);
|
|
464
488
|
if (thisGen !== loadGeneration) return;
|
|
465
489
|
const duckGeoField = duckSchema.find((f: { name: string }) => f.name === geoCol);
|
|
466
490
|
if (duckGeoField) {
|
|
@@ -490,7 +514,7 @@ async function loadTable() {
|
|
|
490
514
|
// (native Parquet GEOMETRY without "geo" KV metadata), use DuckDB
|
|
491
515
|
if (metaFromHyparquet && needsDuckDbCrs && geoCol) {
|
|
492
516
|
try {
|
|
493
|
-
sourceCrs = await engine.detectCrs(
|
|
517
|
+
sourceCrs = await engine.detectCrs(cid, resolved, geoCol);
|
|
494
518
|
if (thisGen !== loadGeneration) return;
|
|
495
519
|
if (sourceCrs) {
|
|
496
520
|
loadProgress = [...loadProgress, { label: t('progress.crs'), value: sourceCrs }];
|
|
@@ -520,7 +544,7 @@ async function loadTable() {
|
|
|
520
544
|
];
|
|
521
545
|
|
|
522
546
|
if (engine.getSchemaAndCrs) {
|
|
523
|
-
const result = await engine.getSchemaAndCrs(
|
|
547
|
+
const result = await engine.getSchemaAndCrs(cid, resolved, findGeoColumn);
|
|
524
548
|
if (thisGen !== loadGeneration) return;
|
|
525
549
|
schema = result.schema;
|
|
526
550
|
columns = schema.map((f) => f.name);
|
|
@@ -547,7 +571,7 @@ async function loadTable() {
|
|
|
547
571
|
}
|
|
548
572
|
}
|
|
549
573
|
} else {
|
|
550
|
-
schema = await engine.getSchema(
|
|
574
|
+
schema = await engine.getSchema(cid, resolved);
|
|
551
575
|
if (thisGen !== loadGeneration) return;
|
|
552
576
|
columns = schema.map((f) => f.name);
|
|
553
577
|
const colPreview =
|
|
@@ -568,7 +592,7 @@ async function loadTable() {
|
|
|
568
592
|
...loadProgress,
|
|
569
593
|
{ label: t('progress.geometry'), value: `${detectedGeoCol} (${geoColType})` }
|
|
570
594
|
];
|
|
571
|
-
sourceCrs = await engine.detectCrs(
|
|
595
|
+
sourceCrs = await engine.detectCrs(cid, resolved, detectedGeoCol);
|
|
572
596
|
if (thisGen !== loadGeneration) return;
|
|
573
597
|
if (sourceCrs) {
|
|
574
598
|
loadProgress = [...loadProgress, { label: t('progress.crs'), value: sourceCrs }];
|
|
@@ -599,7 +623,7 @@ async function loadTable() {
|
|
|
599
623
|
// Retry with enable_geoparquet_conversion=false and BLOB handling.
|
|
600
624
|
if (!result && error && isParquet && geoCol && !isLegacyGeoParquet) {
|
|
601
625
|
try {
|
|
602
|
-
await engine.query(
|
|
626
|
+
await engine.query(cid, 'SET enable_geoparquet_conversion = false');
|
|
603
627
|
geoColType = 'BLOB';
|
|
604
628
|
sqlQuery = buildDefaultSql(0);
|
|
605
629
|
customSql = sqlQuery;
|
|
@@ -673,7 +697,7 @@ async function loadTable() {
|
|
|
673
697
|
} else {
|
|
674
698
|
loadStage = t('table.countingRows');
|
|
675
699
|
engine
|
|
676
|
-
.getRowCount(
|
|
700
|
+
.getRowCount(cid, resolved)
|
|
677
701
|
.then((count) => {
|
|
678
702
|
if (thisGen === loadGeneration) {
|
|
679
703
|
totalRows = count;
|
|
@@ -695,11 +719,14 @@ async function loadTable() {
|
|
|
695
719
|
}
|
|
696
720
|
|
|
697
721
|
async function executeQuery(sql: string) {
|
|
722
|
+
// Snapshot `connId` — reading the $derived after the effect is destroyed
|
|
723
|
+
// returns a Symbol sentinel and crashes downstream template literals.
|
|
724
|
+
const cid = connId;
|
|
698
725
|
try {
|
|
699
726
|
const engine = await getQueryEngine();
|
|
700
727
|
|
|
701
728
|
if (engine.queryCancellable) {
|
|
702
|
-
const handle = engine.queryCancellable(
|
|
729
|
+
const handle = engine.queryCancellable(cid, sql);
|
|
703
730
|
activeHandle = handle;
|
|
704
731
|
try {
|
|
705
732
|
const result = await handle.result;
|
|
@@ -716,7 +743,7 @@ async function executeQuery(sql: string) {
|
|
|
716
743
|
}
|
|
717
744
|
}
|
|
718
745
|
|
|
719
|
-
const result = await engine.query(
|
|
746
|
+
const result = await engine.query(cid, sql);
|
|
720
747
|
columns = result.columns;
|
|
721
748
|
rows = result.rows;
|
|
722
749
|
return result;
|
|
@@ -746,6 +773,8 @@ async function loadPage(page: number) {
|
|
|
746
773
|
}
|
|
747
774
|
|
|
748
775
|
async function runCustomSql() {
|
|
776
|
+
// Snapshot before any await — see note in executeQuery.
|
|
777
|
+
const cid = connId;
|
|
749
778
|
queryRunning = true;
|
|
750
779
|
error = null;
|
|
751
780
|
isCustomQuery = true;
|
|
@@ -768,7 +797,7 @@ async function runCustomSql() {
|
|
|
768
797
|
timestamp: Date.now(),
|
|
769
798
|
durationMs: executionTimeMs,
|
|
770
799
|
rowCount: rows.length,
|
|
771
|
-
connectionId:
|
|
800
|
+
connectionId: cid || undefined
|
|
772
801
|
});
|
|
773
802
|
} catch (err) {
|
|
774
803
|
executionTimeMs = Math.round(performance.now() - start);
|
|
@@ -780,7 +809,7 @@ async function runCustomSql() {
|
|
|
780
809
|
durationMs: executionTimeMs,
|
|
781
810
|
rowCount: 0,
|
|
782
811
|
error: error ?? undefined,
|
|
783
|
-
connectionId:
|
|
812
|
+
connectionId: cid || undefined
|
|
784
813
|
});
|
|
785
814
|
} finally {
|
|
786
815
|
queryRunning = false;
|
|
@@ -984,7 +1013,7 @@ function setStacView() {
|
|
|
984
1013
|
<FileInfo
|
|
985
1014
|
entries={loadProgress}
|
|
986
1015
|
{schema}
|
|
987
|
-
parquetUrl={/\.parquet$/i.test(tab.path) ?
|
|
1016
|
+
parquetUrl={/\.parquet$/i.test(tab.path) ? parquetIframeUrl : ''}
|
|
988
1017
|
/>
|
|
989
1018
|
</div>
|
|
990
1019
|
{:else if viewMode === 'stac'}
|
|
@@ -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'}
|