@walkthru-earth/objex 1.4.0 → 1.5.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 +9 -9
- package/dist/components/layout/ConnectionDialog.svelte +7 -2
- package/dist/components/layout/SettingsSheet.svelte +2 -1
- package/dist/components/layout/StatusBar.svelte +16 -13
- package/dist/components/layout/TabBar.svelte +2 -2
- package/dist/components/viewers/ArchiveViewer.svelte +139 -112
- package/dist/components/viewers/CodeViewer.svelte +15 -27
- package/dist/components/viewers/CodeViewer.svelte.d.ts +1 -1
- package/dist/components/viewers/CogViewer.svelte +8 -6
- package/dist/components/viewers/CopcViewer.svelte +8 -15
- package/dist/components/viewers/DatabaseViewer.svelte +22 -21
- package/dist/components/viewers/FileInfo.svelte +16 -16
- package/dist/components/viewers/FlatGeobufViewer.svelte +15 -45
- package/dist/components/viewers/GeoParquetMapViewer.svelte +5 -3
- package/dist/components/viewers/ImageViewer.svelte +10 -12
- package/dist/components/viewers/LoadProgress.svelte +6 -6
- package/dist/components/viewers/MarkdownViewer.svelte +17 -21
- package/dist/components/viewers/MediaViewer.svelte +11 -12
- package/dist/components/viewers/ModelViewer.svelte +17 -20
- package/dist/components/viewers/MultiCogViewer.svelte +11 -8
- package/dist/components/viewers/NotebookViewer.svelte +22 -26
- package/dist/components/viewers/PdfViewer.svelte +22 -31
- package/dist/components/viewers/PmtilesViewer.svelte +10 -9
- package/dist/components/viewers/QueryHistoryPanel.svelte +18 -18
- package/dist/components/viewers/RawViewer.svelte +21 -18
- package/dist/components/viewers/StacMapViewer.svelte +6 -13
- package/dist/components/viewers/StacMosaicViewer.svelte +9 -7
- package/dist/components/viewers/StacTabViewer.svelte +2 -2
- package/dist/components/viewers/TableGrid.svelte +34 -30
- package/dist/components/viewers/TableStatusBar.svelte +6 -6
- package/dist/components/viewers/TableToolbar.svelte +9 -8
- package/dist/components/viewers/TableViewer.svelte +22 -13
- package/dist/components/viewers/ViewerHeader.svelte +18 -0
- package/dist/components/viewers/ViewerHeader.svelte.d.ts +10 -0
- package/dist/components/viewers/ViewerStatus.svelte +19 -0
- package/dist/components/viewers/ViewerStatus.svelte.d.ts +7 -0
- package/dist/components/viewers/ZarrMapViewer.svelte +13 -12
- package/dist/components/viewers/ZarrViewer.svelte +94 -61
- package/dist/components/viewers/map/AttributeTable.svelte +6 -6
- package/dist/components/viewers/map/MapContainer.svelte +2 -2
- package/dist/components/viewers/pmtiles/PmtilesArchiveView.svelte +109 -83
- package/dist/components/viewers/pmtiles/PmtilesTileInspector.svelte +16 -16
- package/dist/constants.d.ts +6 -0
- package/dist/constants.js +8 -0
- package/dist/i18n/ar.js +3 -0
- package/dist/i18n/en.js +3 -0
- package/dist/query/stac-source-parquet.js +8 -5
- package/dist/query/wasm.js +6 -63
- package/dist/storage/presign.js +2 -1
- package/dist/storage/providers.js +2 -1
- package/dist/stores/settings.svelte.js +3 -3
- package/dist/utils/deck.d.ts +2 -0
- package/dist/utils/deck.js +5 -3
- package/dist/utils/media-query.svelte.d.ts +14 -0
- package/dist/utils/media-query.svelte.js +29 -0
- package/dist/utils/signed-url-effect.d.ts +7 -0
- package/dist/utils/signed-url-effect.js +19 -0
- package/package.json +2 -2
|
@@ -11,6 +11,8 @@ import { t } from '../../i18n/index.svelte.js';
|
|
|
11
11
|
import { getAdapter } from '../../storage/index.js';
|
|
12
12
|
import { tabResources } from '../../stores/tab-resources.svelte.js';
|
|
13
13
|
import type { Tab } from '../../types';
|
|
14
|
+
import ViewerHeader from './ViewerHeader.svelte';
|
|
15
|
+
import ViewerStatus from './ViewerStatus.svelte';
|
|
14
16
|
|
|
15
17
|
let { tab }: { tab: Tab } = $props();
|
|
16
18
|
|
|
@@ -69,30 +71,31 @@ async function loadHexDump() {
|
|
|
69
71
|
</script>
|
|
70
72
|
|
|
71
73
|
<div class="flex h-full flex-col">
|
|
72
|
-
<
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
{
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
</span>
|
|
83
|
-
{#if truncated}
|
|
84
|
-
<span class="hidden text-xs text-amber-500 sm:inline">
|
|
85
|
-
({t('raw.showingFirst').replace('{size}', formatFileSize(MAX_BYTES))})
|
|
74
|
+
<ViewerHeader {tab}>
|
|
75
|
+
{#snippet badge()}
|
|
76
|
+
{#if tab.extension}
|
|
77
|
+
<Badge variant="secondary">{tab.extension}</Badge>
|
|
78
|
+
{/if}
|
|
79
|
+
{/snippet}
|
|
80
|
+
{#snippet actions()}
|
|
81
|
+
{#if !loading && fileSize > 0}
|
|
82
|
+
<span class="hidden text-xs text-muted-foreground sm:inline">
|
|
83
|
+
{formatFileSize(fileSize)}
|
|
86
84
|
</span>
|
|
85
|
+
{#if truncated}
|
|
86
|
+
<span class="hidden text-xs text-amber-500 sm:inline">
|
|
87
|
+
({t('raw.showingFirst').replace('{size}', formatFileSize(MAX_BYTES))})
|
|
88
|
+
</span>
|
|
89
|
+
{/if}
|
|
87
90
|
{/if}
|
|
88
|
-
{/
|
|
89
|
-
</
|
|
91
|
+
{/snippet}
|
|
92
|
+
</ViewerHeader>
|
|
90
93
|
|
|
91
94
|
<div class="flex-1 overflow-auto bg-zinc-950 p-4 font-mono text-xs">
|
|
92
95
|
{#if loading}
|
|
93
|
-
<
|
|
96
|
+
<ViewerStatus kind="loading" message={t('raw.loading')} />
|
|
94
97
|
{:else if error}
|
|
95
|
-
<
|
|
98
|
+
<ViewerStatus kind="error" message={error} />
|
|
96
99
|
{:else}
|
|
97
100
|
<table class="w-full border-collapse">
|
|
98
101
|
<thead>
|
|
@@ -1,23 +1,16 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import type { Tab } from '../../types';
|
|
3
|
-
import {
|
|
3
|
+
import { resolveSignedTabUrl } from '../../utils/signed-url-effect.js';
|
|
4
4
|
|
|
5
5
|
let { tab, variant = 'stac-map' }: { tab: Tab; variant?: 'stac-map' | 'stac-browser' } = $props();
|
|
6
6
|
|
|
7
7
|
let fileUrl = $state('');
|
|
8
8
|
|
|
9
|
-
$effect(() =>
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
if (cancelled || id !== tab.id) return;
|
|
15
|
-
fileUrl = url;
|
|
16
|
-
})();
|
|
17
|
-
return () => {
|
|
18
|
-
cancelled = true;
|
|
19
|
-
};
|
|
20
|
-
});
|
|
9
|
+
$effect(() =>
|
|
10
|
+
resolveSignedTabUrl(tab, (u) => {
|
|
11
|
+
fileUrl = u;
|
|
12
|
+
})
|
|
13
|
+
);
|
|
21
14
|
|
|
22
15
|
const iframeSrc = $derived.by(() => {
|
|
23
16
|
if (!fileUrl) return '';
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
extractMosaicAssets,
|
|
23
23
|
type FacetState,
|
|
24
24
|
formatFileSize,
|
|
25
|
+
handleLoadError,
|
|
25
26
|
hasActiveFilters,
|
|
26
27
|
isAbortError,
|
|
27
28
|
isSingleAssetComposite,
|
|
@@ -36,7 +37,8 @@ import {
|
|
|
36
37
|
type StacItemView,
|
|
37
38
|
type StacRoutableKind,
|
|
38
39
|
smokeTestHref,
|
|
39
|
-
spatialCellKey
|
|
40
|
+
spatialCellKey,
|
|
41
|
+
TILE_DEBOUNCE_MS
|
|
40
42
|
} from '@walkthru-earth/objex-utils';
|
|
41
43
|
import type maplibregl from 'maplibre-gl';
|
|
42
44
|
import { onDestroy, untrack } from 'svelte';
|
|
@@ -503,7 +505,7 @@ const mosaicLayer = $derived.by(() => {
|
|
|
503
505
|
console.warn('[StacMosaic] getSource failed', {
|
|
504
506
|
id: source.id,
|
|
505
507
|
href: source.href,
|
|
506
|
-
error: err
|
|
508
|
+
error: handleLoadError(err) ?? String(err)
|
|
507
509
|
});
|
|
508
510
|
}
|
|
509
511
|
return undefined as unknown as GeoTIFF;
|
|
@@ -669,7 +671,7 @@ const multiCogLayers = $derived.by(() => {
|
|
|
669
671
|
// layers, so the aggregate concurrency budget is even tighter —
|
|
670
672
|
// keep `maxRequests` low.
|
|
671
673
|
maxRequests: 6,
|
|
672
|
-
debounceTime:
|
|
674
|
+
debounceTime: TILE_DEBOUNCE_MS,
|
|
673
675
|
onTileError: (err: Error) => {
|
|
674
676
|
if (isAbortError(err)) return;
|
|
675
677
|
logTileErrorOnce(view.id, err);
|
|
@@ -1346,9 +1348,9 @@ async function loadMosaic(map: maplibregl.Map): Promise<void> {
|
|
|
1346
1348
|
if (gen !== loadGen || signal.aborted) return;
|
|
1347
1349
|
if (!result.ok) smokeWarning = result.reason;
|
|
1348
1350
|
} catch (err) {
|
|
1349
|
-
if (err
|
|
1351
|
+
if (isAbortError(err)) return;
|
|
1350
1352
|
if (gen !== loadGen) return;
|
|
1351
|
-
smokeWarning =
|
|
1353
|
+
smokeWarning = handleLoadError(err);
|
|
1352
1354
|
}
|
|
1353
1355
|
})();
|
|
1354
1356
|
}
|
|
@@ -1444,8 +1446,8 @@ async function loadMosaic(map: maplibregl.Map): Promise<void> {
|
|
|
1444
1446
|
} catch (err) {
|
|
1445
1447
|
if (gen !== loadGen) return;
|
|
1446
1448
|
if (signal.aborted) return;
|
|
1447
|
-
if (err
|
|
1448
|
-
error =
|
|
1449
|
+
if (isAbortError(err)) return;
|
|
1450
|
+
error = handleLoadError(err);
|
|
1449
1451
|
stage = 'error';
|
|
1450
1452
|
loading = false;
|
|
1451
1453
|
}
|
|
@@ -105,10 +105,10 @@ const rawContentMode: ViewMode = $derived(isParquet ? 'table' : 'code');
|
|
|
105
105
|
<div class="flex h-full flex-col overflow-hidden">
|
|
106
106
|
{#key tab.id}
|
|
107
107
|
<div
|
|
108
|
-
class="flex items-center gap-1 border-b border-
|
|
108
|
+
class="flex items-center gap-1 border-b border-border px-2 py-1.5 sm:gap-2 sm:px-4"
|
|
109
109
|
>
|
|
110
110
|
<span
|
|
111
|
-
class="max-w-[120px] truncate text-sm font-medium text-
|
|
111
|
+
class="max-w-[120px] truncate text-sm font-medium text-foreground sm:max-w-none"
|
|
112
112
|
>
|
|
113
113
|
{tab.name}
|
|
114
114
|
</span>
|
|
@@ -77,25 +77,27 @@ const tableWidth = $derived(
|
|
|
77
77
|
ROW_NUM_WIDTH + columns.reduce((sum, col) => sum + (columnWidths[col] || DEFAULT_WIDTH), 0)
|
|
78
78
|
);
|
|
79
79
|
|
|
80
|
-
function startResize(col: string, e:
|
|
80
|
+
function startResize(col: string, e: PointerEvent) {
|
|
81
81
|
e.preventDefault();
|
|
82
82
|
e.stopPropagation();
|
|
83
|
+
(e.target as HTMLElement).setPointerCapture?.(e.pointerId);
|
|
83
84
|
const startX = e.clientX;
|
|
84
85
|
const startW = columnWidths[col] || DEFAULT_WIDTH;
|
|
85
86
|
|
|
86
|
-
function onMove(ev:
|
|
87
|
+
function onMove(ev: PointerEvent) {
|
|
87
88
|
columnWidths[col] = Math.max(MIN_WIDTH, startW + (ev.clientX - startX));
|
|
88
89
|
}
|
|
89
|
-
function onUp() {
|
|
90
|
-
|
|
91
|
-
document.removeEventListener('
|
|
90
|
+
function onUp(ev: PointerEvent) {
|
|
91
|
+
(ev.target as HTMLElement).releasePointerCapture?.(ev.pointerId);
|
|
92
|
+
document.removeEventListener('pointermove', onMove);
|
|
93
|
+
document.removeEventListener('pointerup', onUp);
|
|
92
94
|
resizeCleanup = null;
|
|
93
95
|
}
|
|
94
|
-
document.addEventListener('
|
|
95
|
-
document.addEventListener('
|
|
96
|
+
document.addEventListener('pointermove', onMove);
|
|
97
|
+
document.addEventListener('pointerup', onUp);
|
|
96
98
|
resizeCleanup = () => {
|
|
97
|
-
document.removeEventListener('
|
|
98
|
-
document.removeEventListener('
|
|
99
|
+
document.removeEventListener('pointermove', onMove);
|
|
100
|
+
document.removeEventListener('pointerup', onUp);
|
|
99
101
|
};
|
|
100
102
|
}
|
|
101
103
|
|
|
@@ -221,16 +223,16 @@ function isNull(value: any): boolean {
|
|
|
221
223
|
</colgroup>
|
|
222
224
|
|
|
223
225
|
<thead class="sticky top-0 z-10">
|
|
224
|
-
<tr class="bg-
|
|
226
|
+
<tr class="bg-muted">
|
|
225
227
|
<th
|
|
226
|
-
class="border-b border-e border-
|
|
228
|
+
class="border-b border-e border-border px-2 py-2 text-start text-xs font-medium text-muted-foreground"
|
|
227
229
|
>
|
|
228
230
|
#
|
|
229
231
|
</th>
|
|
230
232
|
{#each columns as col}
|
|
231
233
|
{@const category = columnCategories[col]}
|
|
232
234
|
<th
|
|
233
|
-
class="group relative select-none border-b border-e border-
|
|
235
|
+
class="group relative select-none border-b border-e border-border px-3 py-1.5"
|
|
234
236
|
class:text-start={category !== 'number'}
|
|
235
237
|
class:text-end={category === 'number'}
|
|
236
238
|
class:cursor-pointer={!!onSort}
|
|
@@ -243,7 +245,7 @@ function isNull(value: any): boolean {
|
|
|
243
245
|
>
|
|
244
246
|
{typeLabel(category)}
|
|
245
247
|
</span>
|
|
246
|
-
<span class="truncate text-xs font-semibold text-
|
|
248
|
+
<span class="truncate text-xs font-semibold text-foreground">{col}</span>
|
|
247
249
|
{#if sortColumn === col}
|
|
248
250
|
{#if sortDirection === 'asc'}
|
|
249
251
|
<ArrowUpIcon class="size-3 shrink-0 text-blue-500" />
|
|
@@ -253,18 +255,20 @@ function isNull(value: any): boolean {
|
|
|
253
255
|
{/if}
|
|
254
256
|
</div>
|
|
255
257
|
{#if columnTypes[col]}
|
|
256
|
-
<div class="mt-0.5 truncate text-[10px] font-normal text-
|
|
258
|
+
<div class="mt-0.5 truncate text-[10px] font-normal text-muted-foreground">
|
|
257
259
|
{columnTypes[col]}
|
|
258
260
|
</div>
|
|
259
261
|
{/if}
|
|
260
262
|
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
|
261
263
|
<div
|
|
262
|
-
class="absolute end-0 top-0 h-full w-
|
|
263
|
-
|
|
264
|
+
class="absolute end-0 top-0 h-full w-4 cursor-col-resize touch-none px-1.5"
|
|
265
|
+
onpointerdown={(e) => startResize(col, e)}
|
|
264
266
|
ondblclick={(e) => { e.stopPropagation(); resetWidth(col); }}
|
|
265
267
|
role="separator"
|
|
266
268
|
aria-orientation="vertical"
|
|
267
|
-
|
|
269
|
+
>
|
|
270
|
+
<div class="h-full w-full bg-transparent transition-colors hover:bg-blue-400/60"></div>
|
|
271
|
+
</div>
|
|
268
272
|
</th>
|
|
269
273
|
{/each}
|
|
270
274
|
</tr>
|
|
@@ -274,7 +278,7 @@ function isNull(value: any): boolean {
|
|
|
274
278
|
{#each displayRows as row, i (i)}
|
|
275
279
|
<tr class="hover:bg-blue-50/50 dark:hover:bg-zinc-800/40">
|
|
276
280
|
<td
|
|
277
|
-
class="border-b border-e border-
|
|
281
|
+
class="border-b border-e border-border px-2 py-1 text-xs tabular-nums text-muted-foreground"
|
|
278
282
|
>
|
|
279
283
|
{i + 1}
|
|
280
284
|
</td>
|
|
@@ -283,14 +287,14 @@ function isNull(value: any): boolean {
|
|
|
283
287
|
{@const cellValue = row[col]}
|
|
284
288
|
{@const cellIsNull = isNull(cellValue)}
|
|
285
289
|
<td
|
|
286
|
-
class="overflow-hidden text-ellipsis whitespace-nowrap border-b border-e border-
|
|
290
|
+
class="overflow-hidden text-ellipsis whitespace-nowrap border-b border-e border-border px-3 py-1 text-[13px]"
|
|
287
291
|
class:text-end={category === 'number' && !cellIsNull}
|
|
288
292
|
class:font-mono={category === 'number' && !cellIsNull}
|
|
289
293
|
title={cellIsNull ? 'NULL' : formatCell(cellValue, category)}
|
|
290
294
|
oncontextmenu={(e) => handleContextMenu(e, cellValue, row, col)}
|
|
291
295
|
>
|
|
292
296
|
{#if cellIsNull}
|
|
293
|
-
<span class="text-[11px] italic text-
|
|
297
|
+
<span class="text-[11px] italic text-muted-foreground">null</span>
|
|
294
298
|
{:else if typeof cellValue === 'boolean'}
|
|
295
299
|
{#if cellValue}
|
|
296
300
|
<CheckIcon class="inline size-3.5 text-green-500" />
|
|
@@ -298,7 +302,7 @@ function isNull(value: any): boolean {
|
|
|
298
302
|
<XIcon class="inline size-3.5 text-red-400" />
|
|
299
303
|
{/if}
|
|
300
304
|
{:else}
|
|
301
|
-
<span class="text-
|
|
305
|
+
<span class="text-foreground">
|
|
302
306
|
{formatCell(cellValue, category)}
|
|
303
307
|
</span>
|
|
304
308
|
{/if}
|
|
@@ -310,7 +314,7 @@ function isNull(value: any): boolean {
|
|
|
310
314
|
</table>
|
|
311
315
|
|
|
312
316
|
{#if renderedCount < rows.length}
|
|
313
|
-
<div class="py-2 text-center text-xs text-
|
|
317
|
+
<div class="py-2 text-center text-xs text-muted-foreground">
|
|
314
318
|
{t('statusBar.rowsLabel')}: {renderedCount.toLocaleString()} / {rows.length.toLocaleString()} — scroll for more
|
|
315
319
|
</div>
|
|
316
320
|
{/if}
|
|
@@ -319,36 +323,36 @@ function isNull(value: any): boolean {
|
|
|
319
323
|
<!-- Context menu -->
|
|
320
324
|
{#if ctxMenu}
|
|
321
325
|
<div
|
|
322
|
-
class="fixed z-50 min-w-40 rounded-lg border border-
|
|
326
|
+
class="fixed z-50 min-w-40 rounded-lg border border-border bg-background py-1 shadow-xl"
|
|
323
327
|
style="left: {ctxMenu.x}px; top: {ctxMenu.y}px;"
|
|
324
328
|
role="menu"
|
|
325
329
|
>
|
|
326
330
|
<button
|
|
327
|
-
class="flex w-full items-center gap-2 px-3 py-1.5 text-start text-xs text-
|
|
331
|
+
class="flex w-full items-center gap-2 px-3 py-1.5 text-start text-xs text-foreground hover:bg-muted"
|
|
328
332
|
onclick={copyCell}
|
|
329
333
|
role="menuitem"
|
|
330
334
|
>
|
|
331
|
-
<ClipboardIcon class="size-3.5 text-
|
|
335
|
+
<ClipboardIcon class="size-3.5 text-muted-foreground" />
|
|
332
336
|
{t('table.copyCell')}
|
|
333
337
|
</button>
|
|
334
338
|
<button
|
|
335
|
-
class="flex w-full items-center gap-2 px-3 py-1.5 text-start text-xs text-
|
|
339
|
+
class="flex w-full items-center gap-2 px-3 py-1.5 text-start text-xs text-foreground hover:bg-muted"
|
|
336
340
|
onclick={copyRow}
|
|
337
341
|
role="menuitem"
|
|
338
342
|
>
|
|
339
|
-
<RowsIcon class="size-3.5 text-
|
|
343
|
+
<RowsIcon class="size-3.5 text-muted-foreground" />
|
|
340
344
|
{t('table.copyRow')}
|
|
341
345
|
</button>
|
|
342
346
|
<button
|
|
343
|
-
class="flex w-full items-center gap-2 px-3 py-1.5 text-start text-xs text-
|
|
347
|
+
class="flex w-full items-center gap-2 px-3 py-1.5 text-start text-xs text-foreground hover:bg-muted"
|
|
344
348
|
onclick={copyColumn}
|
|
345
349
|
role="menuitem"
|
|
346
350
|
>
|
|
347
|
-
<ColumnsIcon class="size-3.5 text-
|
|
351
|
+
<ColumnsIcon class="size-3.5 text-muted-foreground" />
|
|
348
352
|
{t('table.copyColumn')}
|
|
349
353
|
</button>
|
|
350
354
|
{#if copied}
|
|
351
|
-
<div class="border-t border-
|
|
355
|
+
<div class="border-t border-border px-3 py-1 text-center text-[10px] text-green-500">
|
|
352
356
|
Copied!
|
|
353
357
|
</div>
|
|
354
358
|
{/if}
|
|
@@ -39,7 +39,7 @@ function handleClickOutside(e: MouseEvent) {
|
|
|
39
39
|
|
|
40
40
|
<svelte:window onclick={() => { if (exportOpen) exportOpen = false; }} />
|
|
41
41
|
|
|
42
|
-
<div class="flex h-7 items-center justify-between border-t border-
|
|
42
|
+
<div class="flex h-7 items-center justify-between border-t border-border bg-muted px-3 text-xs text-muted-foreground">
|
|
43
43
|
<!-- Left side -->
|
|
44
44
|
<div>
|
|
45
45
|
{#if loading}
|
|
@@ -47,7 +47,7 @@ function handleClickOutside(e: MouseEvent) {
|
|
|
47
47
|
{:else if rowCount > 0}
|
|
48
48
|
<span>{rowCount.toLocaleString()} {t('statusBar.rowsLabel')}</span>
|
|
49
49
|
{#if executionTimeMs > 0}
|
|
50
|
-
<span class="text-
|
|
50
|
+
<span class="text-muted-foreground"> {t('statusBar.inTime', { time: executionTimeMs })}</span>
|
|
51
51
|
{/if}
|
|
52
52
|
{:else}
|
|
53
53
|
<span>{t('statusBar.noResults')}</span>
|
|
@@ -57,7 +57,7 @@ function handleClickOutside(e: MouseEvent) {
|
|
|
57
57
|
<!-- Right side: export dropdown -->
|
|
58
58
|
<div class="relative">
|
|
59
59
|
<button
|
|
60
|
-
class="flex items-center gap-1 rounded px-1.5 py-0.5 hover:bg-
|
|
60
|
+
class="flex items-center gap-1 rounded px-1.5 py-0.5 hover:bg-accent"
|
|
61
61
|
onclick={(e) => { e.stopPropagation(); exportOpen = !exportOpen; }}
|
|
62
62
|
disabled={rows.length === 0}
|
|
63
63
|
class:opacity-40={rows.length === 0}
|
|
@@ -69,18 +69,18 @@ function handleClickOutside(e: MouseEvent) {
|
|
|
69
69
|
|
|
70
70
|
{#if exportOpen}
|
|
71
71
|
<div
|
|
72
|
-
class="absolute bottom-full end-0 mb-1 w-32 rounded border border-
|
|
72
|
+
class="absolute bottom-full end-0 mb-1 w-32 rounded border border-border bg-background py-1 shadow-lg"
|
|
73
73
|
role="menu"
|
|
74
74
|
>
|
|
75
75
|
<button
|
|
76
|
-
class="w-full px-3 py-1.5 text-start text-xs hover:bg-
|
|
76
|
+
class="w-full px-3 py-1.5 text-start text-xs hover:bg-muted"
|
|
77
77
|
onclick={(e) => { e.stopPropagation(); handleExportCsv(); }}
|
|
78
78
|
role="menuitem"
|
|
79
79
|
>
|
|
80
80
|
{t('statusBar.exportCsv')}
|
|
81
81
|
</button>
|
|
82
82
|
<button
|
|
83
|
-
class="w-full px-3 py-1.5 text-start text-xs hover:bg-
|
|
83
|
+
class="w-full px-3 py-1.5 text-start text-xs hover:bg-muted"
|
|
84
84
|
onclick={(e) => { e.stopPropagation(); handleExportJson(); }}
|
|
85
85
|
role="menuitem"
|
|
86
86
|
>
|
|
@@ -10,6 +10,7 @@ import InfoIcon from '@lucide/svelte/icons/info';
|
|
|
10
10
|
import LinkIcon from '@lucide/svelte/icons/link';
|
|
11
11
|
import MapIcon from '@lucide/svelte/icons/map';
|
|
12
12
|
import TableIcon from '@lucide/svelte/icons/table';
|
|
13
|
+
import { COPY_FEEDBACK_MS } from '@walkthru-earth/objex-utils';
|
|
13
14
|
import { Button } from '../ui/button/index.js';
|
|
14
15
|
import * as DropdownMenu from '../ui/dropdown-menu/index.js';
|
|
15
16
|
import { Separator } from '../ui/separator/index.js';
|
|
@@ -89,7 +90,7 @@ async function handleCopy(type: 'https' | 'provider') {
|
|
|
89
90
|
try {
|
|
90
91
|
await navigator.clipboard.writeText(url);
|
|
91
92
|
copiedType = type;
|
|
92
|
-
setTimeout(() => (copiedType = null),
|
|
93
|
+
setTimeout(() => (copiedType = null), COPY_FEEDBACK_MS);
|
|
93
94
|
} catch {
|
|
94
95
|
// clipboard API may fail in some contexts
|
|
95
96
|
}
|
|
@@ -117,13 +118,13 @@ function handleJumpKeydown(e: KeyboardEvent) {
|
|
|
117
118
|
</script>
|
|
118
119
|
|
|
119
120
|
<div
|
|
120
|
-
class="flex items-center gap-1 border-b border-
|
|
121
|
+
class="flex items-center gap-1 border-b border-border px-2 py-1.5 sm:gap-2 sm:px-4"
|
|
121
122
|
>
|
|
122
123
|
<!-- File name — truncated on mobile -->
|
|
123
|
-
<span class="truncate max-w-[120px] text-sm font-medium text-
|
|
124
|
+
<span class="truncate max-w-[120px] text-sm font-medium text-foreground sm:max-w-none">{fileName}</span>
|
|
124
125
|
|
|
125
126
|
<!-- Row/col count — hidden on mobile -->
|
|
126
|
-
<span class="hidden text-xs text-
|
|
127
|
+
<span class="hidden text-xs text-muted-foreground sm:inline">
|
|
127
128
|
{#if rowCount > 0}
|
|
128
129
|
{rowCount.toLocaleString()} {t('toolbar.rows')} × {columnCount} cols
|
|
129
130
|
{:else if columnCount > 0}
|
|
@@ -137,7 +138,7 @@ function handleJumpKeydown(e: KeyboardEvent) {
|
|
|
137
138
|
<Button
|
|
138
139
|
variant={viewMode === 'info' ? 'default' : 'outline'}
|
|
139
140
|
size="sm"
|
|
140
|
-
class="h-7 gap-1 px-2 text-xs {viewMode !== 'info' ? 'border-
|
|
141
|
+
class="h-7 gap-1 px-2 text-xs {viewMode !== 'info' ? 'border-border text-muted-foreground hover:bg-muted hover:text-foreground' : ''}"
|
|
141
142
|
onclick={onToggleInfo}
|
|
142
143
|
>
|
|
143
144
|
<InfoIcon class="size-3" />
|
|
@@ -227,7 +228,7 @@ function handleJumpKeydown(e: KeyboardEvent) {
|
|
|
227
228
|
{#if onPageSizeChange && viewMode === 'table'}
|
|
228
229
|
<Separator orientation="vertical" class="!h-4" />
|
|
229
230
|
<select
|
|
230
|
-
class="rounded border border-
|
|
231
|
+
class="rounded border border-border bg-transparent px-1.5 py-0.5 text-xs text-muted-foreground outline-none"
|
|
231
232
|
value={pageSize}
|
|
232
233
|
onchange={(e) => onPageSizeChange?.(parseInt(e.currentTarget.value, 10))}
|
|
233
234
|
>
|
|
@@ -271,7 +272,7 @@ function handleJumpKeydown(e: KeyboardEvent) {
|
|
|
271
272
|
<!-- svelte-ignore a11y_autofocus -->
|
|
272
273
|
<input
|
|
273
274
|
type="number"
|
|
274
|
-
class="h-7 w-14 border border-
|
|
275
|
+
class="h-7 w-14 border border-border bg-transparent px-1 text-center text-xs outline-none"
|
|
275
276
|
bind:value={jumpPageValue}
|
|
276
277
|
onkeydown={handleJumpKeydown}
|
|
277
278
|
onblur={handleJumpSubmit}
|
|
@@ -320,7 +321,7 @@ function handleJumpKeydown(e: KeyboardEvent) {
|
|
|
320
321
|
<div class="flex sm:hidden">
|
|
321
322
|
<DropdownMenu.Root>
|
|
322
323
|
<DropdownMenu.Trigger
|
|
323
|
-
class="rounded p-1 text-
|
|
324
|
+
class="rounded p-1 text-muted-foreground hover:bg-muted"
|
|
324
325
|
>
|
|
325
326
|
<EllipsisVerticalIcon class="size-4" />
|
|
326
327
|
</DropdownMenu.Trigger>
|
|
@@ -7,6 +7,8 @@ import {
|
|
|
7
7
|
extractGeometryTypes,
|
|
8
8
|
findGeoColumn,
|
|
9
9
|
findGeoColumnFromRows,
|
|
10
|
+
handleLoadError,
|
|
11
|
+
isWgs84,
|
|
10
12
|
parseWKB,
|
|
11
13
|
readParquetMetadata,
|
|
12
14
|
toBinary,
|
|
@@ -166,6 +168,9 @@ function buildDefaultSql(
|
|
|
166
168
|
}
|
|
167
169
|
|
|
168
170
|
function extractMapData(queryRows: Record<string, any>[]): MapQueryResult | null {
|
|
171
|
+
// The map attribute table is only consumed by the map view. Skip the
|
|
172
|
+
// O(rows x cols) walk entirely when the table/info/stac view is showing.
|
|
173
|
+
if (viewMode !== 'map') return null;
|
|
169
174
|
if (!geoCol || queryRows.length === 0 || !columns.includes('__wkb')) return null;
|
|
170
175
|
|
|
171
176
|
const wkbArrays: Uint8Array[] = [];
|
|
@@ -245,6 +250,14 @@ $effect(() => {
|
|
|
245
250
|
});
|
|
246
251
|
});
|
|
247
252
|
|
|
253
|
+
// When the user switches into map view and mapData is null (because extraction was
|
|
254
|
+
// skipped by the viewMode gate while in table/info/stac view), compute it now.
|
|
255
|
+
$effect(() => {
|
|
256
|
+
if (viewMode === 'map' && mapData === null && rows.length > 0) {
|
|
257
|
+
mapData = extractMapData(rows);
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
|
|
248
261
|
function cancelLoad() {
|
|
249
262
|
loadGeneration++;
|
|
250
263
|
loadStage = t('table.cancellingQuery');
|
|
@@ -502,11 +515,7 @@ async function loadTable() {
|
|
|
502
515
|
const crsMatch = duckGeoField.type.match(/^GEOMETRY\('([^']+)'\)/i);
|
|
503
516
|
if (crsMatch) {
|
|
504
517
|
const crsVal = crsMatch[1];
|
|
505
|
-
|
|
506
|
-
crsVal === 'EPSG:4326' ||
|
|
507
|
-
crsVal === 'OGC:CRS84' ||
|
|
508
|
-
(crsVal.startsWith('EPSG:') && [4326, 4979].includes(Number(crsVal.split(':')[1])));
|
|
509
|
-
sourceCrs = isWgs84 ? null : crsVal;
|
|
518
|
+
sourceCrs = isWgs84(crsVal) ? null : crsVal;
|
|
510
519
|
needsDuckDbCrs = false;
|
|
511
520
|
} else if (typeStr.startsWith('GEOMETRY')) {
|
|
512
521
|
// GEOMETRY without CRS param — still need CRS from metadata
|
|
@@ -720,7 +729,7 @@ async function loadTable() {
|
|
|
720
729
|
} catch (err) {
|
|
721
730
|
if (thisGen !== loadGeneration) return;
|
|
722
731
|
console.error('[TableViewer] Error:', err);
|
|
723
|
-
error =
|
|
732
|
+
error = handleLoadError(err);
|
|
724
733
|
loading = false;
|
|
725
734
|
loadStage = '';
|
|
726
735
|
}
|
|
@@ -763,7 +772,7 @@ async function executeQuery(sql: string) {
|
|
|
763
772
|
error = t('table.queryCancelled');
|
|
764
773
|
return null;
|
|
765
774
|
}
|
|
766
|
-
error =
|
|
775
|
+
error = handleLoadError(err);
|
|
767
776
|
return null;
|
|
768
777
|
}
|
|
769
778
|
}
|
|
@@ -809,7 +818,7 @@ async function runCustomSql() {
|
|
|
809
818
|
});
|
|
810
819
|
} catch (err) {
|
|
811
820
|
executionTimeMs = Math.round(performance.now() - start);
|
|
812
|
-
error =
|
|
821
|
+
error = handleLoadError(err);
|
|
813
822
|
|
|
814
823
|
queryHistory.add({
|
|
815
824
|
sql: customSql,
|
|
@@ -930,7 +939,7 @@ function setStacView() {
|
|
|
930
939
|
|
|
931
940
|
{#if viewMode === 'table'}
|
|
932
941
|
<!-- SQL Query Bar — hidden during schema/CRS detection, shown once query starts running -->
|
|
933
|
-
<div class="border-b border-
|
|
942
|
+
<div class="border-b border-border px-2 py-1.5 sm:px-4" class:hidden={loading && loadStage !== t('table.runningQuery')}>
|
|
934
943
|
<div class="flex items-start gap-1.5 sm:gap-2">
|
|
935
944
|
<div class="min-w-0 flex-1">
|
|
936
945
|
<CodeMirrorEditor
|
|
@@ -950,7 +959,7 @@ function setStacView() {
|
|
|
950
959
|
{queryRunning ? t('table.running') : t('table.run')}
|
|
951
960
|
</button>
|
|
952
961
|
<button
|
|
953
|
-
class="rounded px-2 py-1 text-xs text-
|
|
962
|
+
class="rounded px-2 py-1 text-xs text-muted-foreground hover:bg-muted sm:px-3"
|
|
954
963
|
onclick={handleFormatSql}
|
|
955
964
|
>
|
|
956
965
|
{t('table.format')}
|
|
@@ -963,9 +972,9 @@ function setStacView() {
|
|
|
963
972
|
<div
|
|
964
973
|
class="border-b border-red-200 bg-red-50 px-4 py-2 dark:border-red-800 dark:bg-red-950"
|
|
965
974
|
>
|
|
966
|
-
<p class="text-xs text-
|
|
975
|
+
<p class="text-xs text-destructive">{error}</p>
|
|
967
976
|
{#if tab.source === 'remote'}
|
|
968
|
-
<p class="mt-1 text-[10px] text-
|
|
977
|
+
<p class="mt-1 text-[10px] text-muted-foreground break-all">{buildStorageUrl(tab)}</p>
|
|
969
978
|
{/if}
|
|
970
979
|
</div>
|
|
971
980
|
{/if}
|
|
@@ -985,7 +994,7 @@ function setStacView() {
|
|
|
985
994
|
<div
|
|
986
995
|
class="max-w-lg rounded-lg border border-red-300 bg-red-50 px-6 py-4 text-center dark:border-red-800 dark:bg-red-950"
|
|
987
996
|
>
|
|
988
|
-
<p class="text-sm text-
|
|
997
|
+
<p class="text-sm text-destructive">{error}</p>
|
|
989
998
|
</div>
|
|
990
999
|
</div>
|
|
991
1000
|
{:else}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
3
|
+
import type { Tab } from '../../types.js';
|
|
4
|
+
|
|
5
|
+
let { tab, badge, actions }: { tab: Tab; badge?: Snippet; actions?: Snippet } = $props();
|
|
6
|
+
</script>
|
|
7
|
+
|
|
8
|
+
<div
|
|
9
|
+
class="flex items-center gap-1 border-b border-border px-2 py-1.5 sm:gap-2 sm:px-4"
|
|
10
|
+
>
|
|
11
|
+
<span class="max-w-[120px] truncate text-sm font-medium text-foreground sm:max-w-none">
|
|
12
|
+
{tab.name}
|
|
13
|
+
</span>
|
|
14
|
+
{#if badge}{@render badge()}{/if}
|
|
15
|
+
{#if actions}
|
|
16
|
+
<div class="ms-auto flex items-center gap-1">{@render actions()}</div>
|
|
17
|
+
{/if}
|
|
18
|
+
</div>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
import type { Tab } from '../../types.js';
|
|
3
|
+
type $$ComponentProps = {
|
|
4
|
+
tab: Tab;
|
|
5
|
+
badge?: Snippet;
|
|
6
|
+
actions?: Snippet;
|
|
7
|
+
};
|
|
8
|
+
declare const ViewerHeader: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
9
|
+
type ViewerHeader = ReturnType<typeof ViewerHeader>;
|
|
10
|
+
export default ViewerHeader;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Loader } from '@lucide/svelte';
|
|
3
|
+
import { t } from '../../i18n/index.svelte.js';
|
|
4
|
+
|
|
5
|
+
let { kind, message }: { kind: 'loading' | 'error' | 'empty'; message?: string } = $props();
|
|
6
|
+
</script>
|
|
7
|
+
|
|
8
|
+
<div class="flex h-full items-center justify-center p-4">
|
|
9
|
+
{#if kind === 'loading'}
|
|
10
|
+
<div class="text-muted-foreground flex items-center gap-2 text-sm">
|
|
11
|
+
<Loader class="size-4 animate-spin" />
|
|
12
|
+
<span>{message ?? t('common.loading')}</span>
|
|
13
|
+
</div>
|
|
14
|
+
{:else if kind === 'error'}
|
|
15
|
+
<p class="text-destructive text-sm">{message ?? t('common.error')}</p>
|
|
16
|
+
{:else}
|
|
17
|
+
<p class="text-muted-foreground text-sm">{message}</p>
|
|
18
|
+
{/if}
|
|
19
|
+
</div>
|