@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.
Files changed (76) hide show
  1. package/README.md +6 -3
  2. package/dist/components/browser/FileTreeSidebar.svelte +1 -1
  3. package/dist/components/layout/ConnectionDialog.svelte +35 -3
  4. package/dist/components/layout/Sidebar.svelte +28 -2
  5. package/dist/components/viewers/ArchiveViewer.svelte +4 -4
  6. package/dist/components/viewers/CodeViewer.svelte +72 -19
  7. package/dist/components/viewers/CodeViewer.svelte.d.ts +11 -1
  8. package/dist/components/viewers/CogControls.svelte +151 -22
  9. package/dist/components/viewers/CogControls.svelte.d.ts +5 -1
  10. package/dist/components/viewers/CogViewer.svelte +45 -10
  11. package/dist/components/viewers/CopcViewer.svelte +20 -2
  12. package/dist/components/viewers/FlatGeobufViewer.svelte +15 -9
  13. package/dist/components/viewers/MultiCogViewer.svelte +416 -0
  14. package/dist/components/viewers/MultiCogViewer.svelte.d.ts +9 -0
  15. package/dist/components/viewers/PmtilesViewer.svelte +2 -2
  16. package/dist/components/viewers/StacMapViewer.svelte +34 -12
  17. package/dist/components/viewers/StacMapViewer.svelte.d.ts +1 -0
  18. package/dist/components/viewers/StacMosaicViewer.svelte +699 -0
  19. package/dist/components/viewers/StacMosaicViewer.svelte.d.ts +9 -0
  20. package/dist/components/viewers/StacTabViewer.svelte +254 -0
  21. package/dist/components/viewers/StacTabViewer.svelte.d.ts +13 -0
  22. package/dist/components/viewers/TableViewer.svelte +50 -21
  23. package/dist/components/viewers/ViewerRouter.svelte +155 -2
  24. package/dist/components/viewers/ViewerRouter.svelte.d.ts +1 -1
  25. package/dist/components/viewers/ZarrMapViewer.svelte +147 -8
  26. package/dist/components/viewers/ZarrMapViewer.svelte.d.ts +8 -2
  27. package/dist/components/viewers/ZarrViewer.svelte +3 -2
  28. package/dist/components/viewers/pmtiles/PmtilesMapView.svelte +0 -1
  29. package/dist/i18n/ar.js +28 -0
  30. package/dist/i18n/en.js +28 -0
  31. package/dist/index.d.ts +4 -0
  32. package/dist/index.js +2 -0
  33. package/dist/query/index.d.ts +1 -1
  34. package/dist/query/index.js +1 -1
  35. package/dist/query/source.d.ts +12 -0
  36. package/dist/query/source.js +25 -8
  37. package/dist/query/stac-geoparquet.d.ts +31 -0
  38. package/dist/query/stac-geoparquet.js +136 -0
  39. package/dist/query/wasm.js +130 -23
  40. package/dist/storage/adapter.d.ts +9 -0
  41. package/dist/storage/adapter.js +13 -1
  42. package/dist/storage/browser-azure.d.ts +1 -1
  43. package/dist/storage/browser-azure.js +4 -0
  44. package/dist/storage/browser-cloud.d.ts +1 -1
  45. package/dist/storage/browser-cloud.js +7 -0
  46. package/dist/storage/presign.d.ts +13 -0
  47. package/dist/storage/presign.js +55 -0
  48. package/dist/storage/providers.d.ts +6 -0
  49. package/dist/storage/providers.js +13 -2
  50. package/dist/stores/browser.svelte.d.ts +2 -0
  51. package/dist/stores/browser.svelte.js +17 -1
  52. package/dist/stores/connections.svelte.d.ts +38 -23
  53. package/dist/stores/connections.svelte.js +105 -114
  54. package/dist/utils/cog.d.ts +80 -18
  55. package/dist/utils/cog.js +187 -125
  56. package/dist/utils/colormap-sprite.d.ts +39 -0
  57. package/dist/utils/colormap-sprite.js +77 -0
  58. package/dist/utils/connection-identity.d.ts +51 -0
  59. package/dist/utils/connection-identity.js +97 -0
  60. package/dist/utils/host-detection.js +48 -302
  61. package/dist/utils/parquet-metadata.d.ts +7 -1
  62. package/dist/utils/parquet-metadata.js +35 -1
  63. package/dist/utils/stac-geoparquet.d.ts +90 -0
  64. package/dist/utils/stac-geoparquet.js +223 -0
  65. package/dist/utils/stac-hydrate.d.ts +38 -0
  66. package/dist/utils/stac-hydrate.js +243 -0
  67. package/dist/utils/stac.d.ts +136 -0
  68. package/dist/utils/stac.js +176 -0
  69. package/dist/utils/storage-url.d.ts +26 -0
  70. package/dist/utils/storage-url.js +164 -28
  71. package/dist/utils/url.d.ts +13 -0
  72. package/dist/utils/url.js +36 -0
  73. package/dist/utils/wkb.js +22 -8
  74. package/dist/utils/zarr.d.ts +34 -0
  75. package/dist/utils/zarr.js +94 -0
  76. 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
- function buildDefaultSql(offset = 0): string {
102
- const resolved = resolveTableSource(tab);
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
- // Set SQL eagerly so editor shows the query while loading
291
- const initialSql = buildDefaultSql(0);
292
- sqlQuery = initialSql;
293
- customSql = initialSql;
303
+ resolvedSource = null;
304
+ const eagerSql = buildDefaultSql(0);
305
+ sqlQuery = eagerSql;
306
+ customSql = eagerSql;
294
307
 
295
308
  try {
296
- const resolved: ResolvedTableSource = resolveTableSource(tab);
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(connId, 'SET enable_geoparquet_conversion = false');
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(connId, resolved);
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(connId, resolved, geoCol);
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(connId, resolved, findGeoColumn);
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(connId, resolved);
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(connId, resolved, detectedGeoCol);
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(connId, 'SET enable_geoparquet_conversion = false');
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(connId, resolved)
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(connId, sql);
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(connId, sql);
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: connId || undefined
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: connId || undefined
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) ? buildHttpsUrl(tab) : ''}
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 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
  };