@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.
Files changed (58) hide show
  1. package/README.md +9 -9
  2. package/dist/components/layout/ConnectionDialog.svelte +7 -2
  3. package/dist/components/layout/SettingsSheet.svelte +2 -1
  4. package/dist/components/layout/StatusBar.svelte +16 -13
  5. package/dist/components/layout/TabBar.svelte +2 -2
  6. package/dist/components/viewers/ArchiveViewer.svelte +139 -112
  7. package/dist/components/viewers/CodeViewer.svelte +15 -27
  8. package/dist/components/viewers/CodeViewer.svelte.d.ts +1 -1
  9. package/dist/components/viewers/CogViewer.svelte +8 -6
  10. package/dist/components/viewers/CopcViewer.svelte +8 -15
  11. package/dist/components/viewers/DatabaseViewer.svelte +22 -21
  12. package/dist/components/viewers/FileInfo.svelte +16 -16
  13. package/dist/components/viewers/FlatGeobufViewer.svelte +15 -45
  14. package/dist/components/viewers/GeoParquetMapViewer.svelte +5 -3
  15. package/dist/components/viewers/ImageViewer.svelte +10 -12
  16. package/dist/components/viewers/LoadProgress.svelte +6 -6
  17. package/dist/components/viewers/MarkdownViewer.svelte +17 -21
  18. package/dist/components/viewers/MediaViewer.svelte +11 -12
  19. package/dist/components/viewers/ModelViewer.svelte +17 -20
  20. package/dist/components/viewers/MultiCogViewer.svelte +11 -8
  21. package/dist/components/viewers/NotebookViewer.svelte +22 -26
  22. package/dist/components/viewers/PdfViewer.svelte +22 -31
  23. package/dist/components/viewers/PmtilesViewer.svelte +10 -9
  24. package/dist/components/viewers/QueryHistoryPanel.svelte +18 -18
  25. package/dist/components/viewers/RawViewer.svelte +21 -18
  26. package/dist/components/viewers/StacMapViewer.svelte +6 -13
  27. package/dist/components/viewers/StacMosaicViewer.svelte +9 -7
  28. package/dist/components/viewers/StacTabViewer.svelte +2 -2
  29. package/dist/components/viewers/TableGrid.svelte +34 -30
  30. package/dist/components/viewers/TableStatusBar.svelte +6 -6
  31. package/dist/components/viewers/TableToolbar.svelte +9 -8
  32. package/dist/components/viewers/TableViewer.svelte +22 -13
  33. package/dist/components/viewers/ViewerHeader.svelte +18 -0
  34. package/dist/components/viewers/ViewerHeader.svelte.d.ts +10 -0
  35. package/dist/components/viewers/ViewerStatus.svelte +19 -0
  36. package/dist/components/viewers/ViewerStatus.svelte.d.ts +7 -0
  37. package/dist/components/viewers/ZarrMapViewer.svelte +13 -12
  38. package/dist/components/viewers/ZarrViewer.svelte +94 -61
  39. package/dist/components/viewers/map/AttributeTable.svelte +6 -6
  40. package/dist/components/viewers/map/MapContainer.svelte +2 -2
  41. package/dist/components/viewers/pmtiles/PmtilesArchiveView.svelte +109 -83
  42. package/dist/components/viewers/pmtiles/PmtilesTileInspector.svelte +16 -16
  43. package/dist/constants.d.ts +6 -0
  44. package/dist/constants.js +8 -0
  45. package/dist/i18n/ar.js +3 -0
  46. package/dist/i18n/en.js +3 -0
  47. package/dist/query/stac-source-parquet.js +8 -5
  48. package/dist/query/wasm.js +6 -63
  49. package/dist/storage/presign.js +2 -1
  50. package/dist/storage/providers.js +2 -1
  51. package/dist/stores/settings.svelte.js +3 -3
  52. package/dist/utils/deck.d.ts +2 -0
  53. package/dist/utils/deck.js +5 -3
  54. package/dist/utils/media-query.svelte.d.ts +14 -0
  55. package/dist/utils/media-query.svelte.js +29 -0
  56. package/dist/utils/signed-url-effect.d.ts +7 -0
  57. package/dist/utils/signed-url-effect.js +19 -0
  58. 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
- <div
73
- 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"
74
- >
75
- <span class="truncate max-w-[120px] text-sm font-medium text-zinc-700 sm:max-w-none dark:text-zinc-300">{tab.name}</span>
76
- {#if tab.extension}
77
- <Badge variant="secondary">{tab.extension}</Badge>
78
- {/if}
79
- {#if !loading && fileSize > 0}
80
- <span class="hidden text-xs text-zinc-400 sm:inline dark:text-zinc-500">
81
- {formatFileSize(fileSize)}
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
- {/if}
89
- </div>
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
- <p class="text-zinc-400">{t('raw.loading')}</p>
96
+ <ViewerStatus kind="loading" message={t('raw.loading')} />
94
97
  {:else if error}
95
- <p class="text-red-400">{error}</p>
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 { buildHttpsUrlAsync } from '../../utils/signed-url.js';
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
- const id = tab.id;
11
- let cancelled = false;
12
- (async () => {
13
- const url = await buildHttpsUrlAsync(tab);
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 instanceof Error ? err.message : 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: 200,
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 instanceof DOMException && err.name === 'AbortError') return;
1351
+ if (isAbortError(err)) return;
1350
1352
  if (gen !== loadGen) return;
1351
- smokeWarning = err instanceof Error ? err.message : String(err);
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 instanceof DOMException && err.name === 'AbortError') return;
1448
- error = err instanceof Error ? err.message : String(err);
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-zinc-200 px-2 py-1.5 sm:gap-2 sm:px-4 dark:border-zinc-800"
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-zinc-700 sm:max-w-none dark:text-zinc-300"
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: MouseEvent) {
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: MouseEvent) {
87
+ function onMove(ev: PointerEvent) {
87
88
  columnWidths[col] = Math.max(MIN_WIDTH, startW + (ev.clientX - startX));
88
89
  }
89
- function onUp() {
90
- document.removeEventListener('mousemove', onMove);
91
- document.removeEventListener('mouseup', onUp);
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('mousemove', onMove);
95
- document.addEventListener('mouseup', onUp);
96
+ document.addEventListener('pointermove', onMove);
97
+ document.addEventListener('pointerup', onUp);
96
98
  resizeCleanup = () => {
97
- document.removeEventListener('mousemove', onMove);
98
- document.removeEventListener('mouseup', onUp);
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-zinc-100 dark:bg-zinc-900">
226
+ <tr class="bg-muted">
225
227
  <th
226
- class="border-b border-e border-zinc-200 px-2 py-2 text-start text-xs font-medium text-zinc-400 dark:border-zinc-700 dark:text-zinc-500"
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-zinc-200 px-3 py-1.5 dark:border-zinc-700"
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-zinc-700 dark:text-zinc-300">{col}</span>
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-zinc-400 dark:text-zinc-500">
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-1.5 cursor-col-resize bg-transparent transition-colors hover:bg-blue-400/60"
263
- onmousedown={(e) => startResize(col, e)}
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
- ></div>
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-zinc-100 px-2 py-1 text-xs tabular-nums text-zinc-400 dark:border-zinc-800 dark:text-zinc-600"
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-zinc-100 px-3 py-1 text-[13px] dark:border-zinc-800"
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-zinc-400 dark:text-zinc-600">null</span>
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-zinc-800 dark:text-zinc-200">
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-zinc-400 dark:text-zinc-600">
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-zinc-200 bg-white py-1 shadow-xl dark:border-zinc-700 dark:bg-zinc-800"
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-zinc-700 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-700"
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-zinc-400" />
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-zinc-700 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-700"
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-zinc-400" />
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-zinc-700 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-700"
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-zinc-400" />
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-zinc-200 px-3 py-1 text-center text-[10px] text-green-500 dark:border-zinc-700">
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-zinc-200 bg-zinc-50 px-3 text-xs text-zinc-500 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-400">
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-zinc-400 dark:text-zinc-500"> {t('statusBar.inTime', { time: executionTimeMs })}</span>
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-zinc-200 dark:hover:bg-zinc-800"
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-zinc-200 bg-white py-1 shadow-lg dark:border-zinc-700 dark:bg-zinc-800"
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-zinc-100 dark:hover:bg-zinc-700"
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-zinc-100 dark:hover:bg-zinc-700"
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), 2000);
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-zinc-200 px-2 py-1.5 sm:gap-2 sm:px-4 dark:border-zinc-800"
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-zinc-700 sm:max-w-none dark:text-zinc-300">{fileName}</span>
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-zinc-400 sm:inline dark:text-zinc-500">
127
+ <span class="hidden text-xs text-muted-foreground sm:inline">
127
128
  {#if rowCount > 0}
128
129
  {rowCount.toLocaleString()} {t('toolbar.rows')} &times; {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-zinc-300 text-zinc-600 hover:bg-zinc-50 hover:text-zinc-700 dark:border-zinc-700 dark:text-zinc-400 dark:hover:bg-zinc-900' : ''}"
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-zinc-200 bg-transparent px-1.5 py-0.5 text-xs text-zinc-500 outline-none dark:border-zinc-700 dark:text-zinc-400"
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-zinc-200 bg-transparent px-1 text-center text-xs outline-none dark:border-zinc-700 dark:bg-zinc-900"
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-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800"
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
- const isWgs84 =
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 = err instanceof Error ? err.message : String(err);
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 = err instanceof Error ? err.message : String(err);
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 = err instanceof Error ? err.message : String(err);
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-zinc-200 px-2 py-1.5 sm:px-4 dark:border-zinc-800" class:hidden={loading && loadStage !== t('table.runningQuery')}>
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-zinc-400 hover:bg-zinc-100 sm:px-3 dark:hover:bg-zinc-800"
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-red-600 dark:text-red-400">{error}</p>
975
+ <p class="text-xs text-destructive">{error}</p>
967
976
  {#if tab.source === 'remote'}
968
- <p class="mt-1 text-[10px] text-zinc-400 break-all">{buildStorageUrl(tab)}</p>
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-red-600 dark:text-red-400">{error}</p>
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>
@@ -0,0 +1,7 @@
1
+ type $$ComponentProps = {
2
+ kind: 'loading' | 'error' | 'empty';
3
+ message?: string;
4
+ };
5
+ declare const ViewerStatus: import("svelte").Component<$$ComponentProps, {}, "">;
6
+ type ViewerStatus = ReturnType<typeof ViewerStatus>;
7
+ export default ViewerStatus;