@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
@@ -1,5 +1,6 @@
1
1
  <script lang="ts">
2
2
  import { MapboxOverlay } from '@deck.gl/mapbox';
3
+ import { handleLoadError } from '@walkthru-earth/objex-utils';
3
4
  import type maplibregl from 'maplibre-gl';
4
5
  import maplibreModule from 'maplibre-gl';
5
6
  import { onDestroy, untrack } from 'svelte';
@@ -491,7 +492,7 @@ async function addZarrLayer(map: maplibregl.Map) {
491
492
  zarrLayer = new ZarrLayer(opts);
492
493
  map.addLayer(zarrLayer);
493
494
  } catch (err) {
494
- error = err instanceof Error ? err.message : String(err);
495
+ error = handleLoadError(err);
495
496
  loading = false;
496
497
  }
497
498
  }
@@ -599,7 +600,7 @@ async function updateSelector() {
599
600
  try {
600
601
  await zarrLayer.setSelector(buildSelector());
601
602
  } catch (err) {
602
- error = err instanceof Error ? err.message : String(err);
603
+ error = handleLoadError(err);
603
604
  }
604
605
  }
605
606
 
@@ -641,12 +642,12 @@ onDestroy(cleanup);
641
642
  <div class="flex h-full w-full flex-col overflow-hidden">
642
643
  <!-- Controls bar -->
643
644
  <div
644
- class="flex flex-wrap items-center gap-x-3 gap-y-1 border-b border-zinc-200 px-3 py-1.5 dark:border-zinc-800"
645
+ class="flex flex-wrap items-center gap-x-3 gap-y-1 border-b border-border px-3 py-1.5"
645
646
  >
646
- <label class="flex items-center gap-1 text-xs text-zinc-400">
647
+ <label class="flex items-center gap-1 text-xs text-muted-foreground">
647
648
  {t('map.variable')}
648
649
  <select
649
- class="rounded border border-zinc-300 bg-white px-1.5 py-0.5 text-xs text-zinc-700 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-300"
650
+ class="rounded border border-border bg-background px-1.5 py-0.5 text-xs text-foreground"
650
651
  bind:value={selectedVar}
651
652
  onchange={changeVariable}
652
653
  >
@@ -658,10 +659,10 @@ onDestroy(cleanup);
658
659
 
659
660
  {#each selectorDims as dim}
660
661
  <label
661
- class="flex shrink-0 items-center gap-1.5 rounded border border-zinc-200 px-2 py-0.5 text-xs text-zinc-400 dark:border-zinc-700"
662
+ class="flex shrink-0 items-center gap-1.5 rounded border border-border px-2 py-0.5 text-xs text-muted-foreground"
662
663
  title={dimLabel(dim)}
663
664
  >
664
- <span class="shrink-0 font-medium text-zinc-500 dark:text-zinc-400">{dim.name}</span>
665
+ <span class="shrink-0 font-medium text-muted-foreground">{dim.name}</span>
665
666
  <Slider
666
667
  type="single"
667
668
  min={0}
@@ -676,7 +677,7 @@ onDestroy(cleanup);
676
677
  />
677
678
  {#if dim.isDatetime && dim.minDate && dim.maxDate}
678
679
  {@const dateVal = indexToDateStr(selectorValues[dim.name] ?? 0, dim)}
679
- <span class="shrink-0 tabular-nums text-zinc-500">
680
+ <span class="shrink-0 tabular-nums text-muted-foreground">
680
681
  {dateVal ? (dim.subDaily ? dateVal.replace('T', ' ') : dateVal) : (selectorValues[dim.name] ?? 0)}
681
682
  </span>
682
683
  <input
@@ -691,10 +692,10 @@ onDestroy(cleanup);
691
692
  updateSelector();
692
693
  }
693
694
  }}
694
- class="h-5 rounded border border-zinc-300 bg-white px-1 text-[10px] text-zinc-600 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-400"
695
+ class="h-5 rounded border border-border bg-background px-1 text-[10px] text-muted-foreground"
695
696
  />
696
697
  {:else}
697
- <span class="shrink-0 tabular-nums text-zinc-500">{selectorValues[dim.name] ?? 0}<span class="text-zinc-500/60">/{dim.size - 1}</span></span>
698
+ <span class="shrink-0 tabular-nums text-muted-foreground">{selectorValues[dim.name] ?? 0}<span class="text-muted-foreground/60">/{dim.size - 1}</span></span>
698
699
  {#if dim.dtype}
699
700
  <span class="shrink-0 text-[10px] text-zinc-400/70">{dim.dtype}</span>
700
701
  {/if}
@@ -703,7 +704,7 @@ onDestroy(cleanup);
703
704
  {/each}
704
705
 
705
706
  {#if selectedMeta?.shape}
706
- <span class="ms-auto text-xs text-zinc-400">
707
+ <span class="ms-auto text-xs text-muted-foreground">
707
708
  {selectedMeta.dtype} [{selectedMeta.shape.join(', ')}]
708
709
  </span>
709
710
  {/if}
@@ -713,7 +714,7 @@ onDestroy(cleanup);
713
714
  <div class="relative min-h-0 flex-1">
714
715
  {#if error && !loading}
715
716
  <div class="flex h-full items-center justify-center">
716
- <p class="max-w-md text-center text-sm text-red-400">{error}</p>
717
+ <p class="max-w-md text-center text-sm text-destructive">{error}</p>
717
718
  </div>
718
719
  {:else}
719
720
  <MapContainer {onMapReady} bounds={[-130, 20, -60, 55]} />
@@ -1,5 +1,6 @@
1
1
  <script lang="ts">
2
- import { untrack } from 'svelte';
2
+ import { handleLoadError } from '@walkthru-earth/objex-utils';
3
+ import { onDestroy, untrack } from 'svelte';
3
4
  import { Badge } from '../ui/badge/index.js';
4
5
  import { Button } from '../ui/button/index.js';
5
6
  import {
@@ -8,6 +9,7 @@ import {
8
9
  ResizablePaneGroup
9
10
  } from '../ui/resizable/index.js';
10
11
  import { t } from '../../i18n/index.svelte.js';
12
+ import { tabResources } from '../../stores/tab-resources.svelte.js';
11
13
  import type { Tab } from '../../types';
12
14
  import { buildHttpsUrlAsync } from '../../utils/signed-url.js';
13
15
  import { pickViewMode, updateUrlView } from '../../utils/url-state.js';
@@ -25,9 +27,12 @@ import {
25
27
  type ZarrHierarchy,
26
28
  type ZarrNode
27
29
  } from '../../utils/zarr.js';
30
+ import { useIsWide } from '../../utils/media-query.svelte.js';
28
31
 
29
32
  let { tab }: { tab: Tab } = $props();
30
33
 
34
+ const isWide = useIsWide();
35
+
31
36
  let loading = $state(true);
32
37
  let error = $state<string | null>(null);
33
38
  type ZarrViewMode = 'inspect' | 'map';
@@ -110,6 +115,19 @@ $effect(() => {
110
115
  });
111
116
  });
112
117
 
118
+ function cleanup() {
119
+ hierarchy = null;
120
+ selectedNode = null;
121
+ expanded = new Set();
122
+ }
123
+
124
+ $effect(() => {
125
+ const id = tab.id;
126
+ const unregister = tabResources.register(id, cleanup);
127
+ return unregister;
128
+ });
129
+ onDestroy(cleanup);
130
+
113
131
  function setViewMode(mode: 'inspect' | 'map') {
114
132
  viewMode = mode;
115
133
  updateUrlView(viewMode);
@@ -133,7 +151,7 @@ async function loadHierarchy() {
133
151
  expanded = new Set(['/']);
134
152
  }
135
153
  } catch (err) {
136
- error = err instanceof Error ? err.message : String(err);
154
+ error = handleLoadError(err);
137
155
  } finally {
138
156
  loading = false;
139
157
  updateUrlView(viewMode);
@@ -182,7 +200,7 @@ function selectStoreAttrs() {
182
200
  {#if hasChildren}
183
201
  <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
184
202
  <span
185
- class="flex size-4 shrink-0 items-center justify-center rounded hover:bg-zinc-200 dark:hover:bg-zinc-700"
203
+ class="flex size-4 shrink-0 items-center justify-center rounded hover:bg-accent"
186
204
  role="button"
187
205
  tabindex="-1"
188
206
  aria-label={isExpanded ? 'Collapse' : 'Expand'}
@@ -192,7 +210,7 @@ function selectStoreAttrs() {
192
210
  }}
193
211
  >
194
212
  <svg
195
- class="size-3 text-zinc-400 transition-transform"
213
+ class="size-3 text-muted-foreground transition-transform"
196
214
  class:rotate-90={isExpanded}
197
215
  viewBox="0 0 16 16"
198
216
  fill="currentColor"
@@ -229,10 +247,8 @@ function selectStoreAttrs() {
229
247
  <span
230
248
  class="truncate"
231
249
  class:font-medium={node.kind === 'array'}
232
- class:text-zinc-700={node.kind === 'array'}
233
- class:dark:text-zinc-300={node.kind === 'array'}
234
- class:text-zinc-600={node.kind === 'group'}
235
- class:dark:text-zinc-400={node.kind === 'group'}
250
+ class:text-foreground={node.kind === 'array'}
251
+ class:text-muted-foreground={node.kind === 'group'}
236
252
  >
237
253
  {node.path === '/' ? '/ (root)' : node.name}
238
254
  </span>
@@ -259,18 +275,18 @@ function selectStoreAttrs() {
259
275
  {#snippet nodeDetails()}
260
276
  {#if showingStoreAttrs && hierarchy}
261
277
  <div
262
- class="shrink-0 border-b border-zinc-200 px-3 py-1.5 text-[11px] font-medium uppercase tracking-wider text-muted-foreground dark:border-zinc-800"
278
+ class="shrink-0 border-b border-border px-3 py-1.5 text-[11px] font-medium uppercase tracking-wider text-muted-foreground"
263
279
  >
264
280
  {t('zarr.storeAttributes')}
265
281
  </div>
266
282
  <div class="flex-1 overflow-auto p-3">
267
283
  <div
268
- class="rounded border border-zinc-200 bg-zinc-100 p-2 text-xs dark:border-zinc-700 dark:bg-zinc-800"
284
+ class="rounded border border-border bg-muted p-2 text-xs"
269
285
  >
270
286
  {#each Object.entries(hierarchy.storeAttrs) as [key, value]}
271
287
  <div class="flex gap-2 py-0.5">
272
288
  <span class="shrink-0 font-medium text-muted-foreground">{key}:</span>
273
- <span class="break-all text-zinc-700 dark:text-zinc-300">
289
+ <span class="break-all text-foreground">
274
290
  {typeof value === 'string' ? value : JSON.stringify(value)}
275
291
  </span>
276
292
  </div>
@@ -279,7 +295,7 @@ function selectStoreAttrs() {
279
295
  </div>
280
296
  {:else if selectedNode}
281
297
  <div
282
- class="shrink-0 border-b border-zinc-200 px-3 py-1.5 text-[11px] font-medium uppercase tracking-wider text-muted-foreground dark:border-zinc-800"
298
+ class="shrink-0 border-b border-border px-3 py-1.5 text-[11px] font-medium uppercase tracking-wider text-muted-foreground"
283
299
  >
284
300
  {selectedNode.path}
285
301
  </div>
@@ -389,12 +405,12 @@ function selectStoreAttrs() {
389
405
  <dt class="text-muted-foreground">{t('zarr.attributes')}</dt>
390
406
  <dd>
391
407
  <div
392
- class="mt-1 rounded border border-zinc-200 bg-zinc-100 p-2 dark:border-zinc-700 dark:bg-zinc-800"
408
+ class="mt-1 rounded border border-border bg-muted p-2"
393
409
  >
394
410
  {#each Object.entries(selectedNode.attributes) as [key, value]}
395
411
  <div class="flex gap-2 py-0.5">
396
412
  <span class="shrink-0 font-medium text-muted-foreground">{key}:</span>
397
- <span class="break-all text-zinc-700 dark:text-zinc-300">
413
+ <span class="break-all text-foreground">
398
414
  {typeof value === 'string' ? value : JSON.stringify(value)}
399
415
  </span>
400
416
  </div>
@@ -414,10 +430,10 @@ function selectStoreAttrs() {
414
430
 
415
431
  <div class="flex h-full flex-col">
416
432
  <!-- Header bar -->
417
- <div class="shrink-0 border-b border-zinc-200 px-3 py-2 sm:px-4 dark:border-zinc-800">
433
+ <div class="shrink-0 border-b border-border px-3 py-2 sm:px-4">
418
434
  <div class="flex items-center gap-1.5 sm:gap-2">
419
435
  <span
420
- class="max-w-[140px] truncate text-sm font-medium text-zinc-700 sm:max-w-none dark:text-zinc-300"
436
+ class="max-w-[140px] truncate text-sm font-medium text-foreground sm:max-w-none"
421
437
  >{tab.name}</span
422
438
  >
423
439
  <Badge
@@ -459,11 +475,11 @@ function selectStoreAttrs() {
459
475
  <!-- Content -->
460
476
  {#if loading}
461
477
  <div class="flex flex-1 items-center justify-center">
462
- <p class="text-sm text-zinc-400">{t('zarr.loading')}</p>
478
+ <p class="text-sm text-muted-foreground">{t('zarr.loading')}</p>
463
479
  </div>
464
480
  {:else if error}
465
481
  <div class="flex flex-1 items-center justify-center">
466
- <p class="max-w-md text-center text-sm text-red-400">{error}</p>
482
+ <p class="max-w-md text-center text-sm text-destructive">{error}</p>
467
483
  </div>
468
484
  {:else if viewMode === 'map' && hasMapVars}
469
485
  {#key viewMode}
@@ -480,56 +496,73 @@ function selectStoreAttrs() {
480
496
  {/key}
481
497
  {:else if hierarchy}
482
498
  <!-- Inspect mode (tree + detail panel) -->
483
- <ResizablePaneGroup direction="horizontal" class="min-h-0 flex-1">
484
- <!-- Left: Tree view -->
485
- <ResizablePane defaultSize={40} minSize={20}>
486
- <div class="flex h-full flex-col">
487
- <div
488
- class="shrink-0 border-b border-zinc-200 px-3 py-1.5 text-[11px] font-medium uppercase tracking-wider text-muted-foreground dark:border-zinc-800"
499
+ {#snippet zarrTree()}
500
+ <div class="flex h-full flex-col">
501
+ <div
502
+ class="shrink-0 border-b border-border px-3 py-1.5 text-[11px] font-medium uppercase tracking-wider text-muted-foreground"
503
+ >
504
+ {t('zarr.contents')}
505
+ <span class="ms-1 normal-case tracking-normal"
506
+ >({hierarchy!.totalNodes})</span
489
507
  >
490
- {t('zarr.contents')}
491
- <span class="ms-1 normal-case tracking-normal"
492
- >({hierarchy.totalNodes})</span
508
+ </div>
509
+ <div class="flex-1 overflow-auto">
510
+ {#if hasStoreAttrs}
511
+ <button
512
+ class="flex w-full items-center gap-2 border-b border-zinc-100 px-3 py-1 text-xs hover:bg-zinc-100 dark:border-zinc-800/50 dark:hover:bg-zinc-800/50"
513
+ class:bg-blue-50={showingStoreAttrs}
514
+ class:dark:bg-blue-950={showingStoreAttrs}
515
+ onclick={selectStoreAttrs}
493
516
  >
494
- </div>
495
- <div class="flex-1 overflow-auto">
496
- {#if hasStoreAttrs}
497
- <button
498
- class="flex w-full items-center gap-2 border-b border-zinc-100 px-3 py-1 text-xs hover:bg-zinc-100 dark:border-zinc-800/50 dark:hover:bg-zinc-800/50"
499
- class:bg-blue-50={showingStoreAttrs}
500
- class:dark:bg-blue-950={showingStoreAttrs}
501
- onclick={selectStoreAttrs}
517
+ <span class="size-4 shrink-0"></span>
518
+ <svg
519
+ class="size-3.5 shrink-0 text-muted-foreground"
520
+ viewBox="0 0 16 16"
521
+ fill="currentColor"
502
522
  >
503
- <span class="size-4 shrink-0"></span>
504
- <svg
505
- class="size-3.5 shrink-0 text-zinc-400"
506
- viewBox="0 0 16 16"
507
- fill="currentColor"
508
- >
509
- <path
510
- fill-rule="evenodd"
511
- d="M8 1a3.5 3.5 0 0 0-3.5 3.5V7H3a2 2 0 0 0-2 2v4a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-1.5V4.5A3.5 3.5 0 0 0 8 1Zm2 6V4.5a2 2 0 1 0-4 0V7h4Z"
512
- clip-rule="evenodd"
513
- />
514
- </svg>
515
- <span class="truncate font-medium text-muted-foreground">
516
- {t('zarr.storeAttributes')}
517
- </span>
518
- </button>
519
- {/if}
520
- {@render treeNode(hierarchy.root, 0)}
521
- </div>
523
+ <path
524
+ fill-rule="evenodd"
525
+ d="M8 1a3.5 3.5 0 0 0-3.5 3.5V7H3a2 2 0 0 0-2 2v4a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-1.5V4.5A3.5 3.5 0 0 0 8 1Zm2 6V4.5a2 2 0 1 0-4 0V7h4Z"
526
+ clip-rule="evenodd"
527
+ />
528
+ </svg>
529
+ <span class="truncate font-medium text-muted-foreground">
530
+ {t('zarr.storeAttributes')}
531
+ </span>
532
+ </button>
533
+ {/if}
534
+ {@render treeNode(hierarchy!.root, 0)}
522
535
  </div>
523
- </ResizablePane>
536
+ </div>
537
+ {/snippet}
538
+
539
+ {#if isWide.value}
540
+ <ResizablePaneGroup direction="horizontal" class="min-h-0 flex-1">
541
+ <!-- Left: Tree view -->
542
+ <ResizablePane defaultSize={40} minSize={20}>
543
+ {@render zarrTree()}
544
+ </ResizablePane>
524
545
 
525
- <ResizableHandle />
546
+ <ResizableHandle />
526
547
 
527
- <!-- Right: Detail panel -->
528
- <ResizablePane defaultSize={60} minSize={30}>
529
- <div class="flex h-full flex-col">
548
+ <!-- Right: Detail panel -->
549
+ <ResizablePane defaultSize={60} minSize={30}>
550
+ <div class="flex h-full flex-col">
551
+ {@render nodeDetails()}
552
+ </div>
553
+ </ResizablePane>
554
+ </ResizablePaneGroup>
555
+ {:else}
556
+ <div class="flex min-h-0 flex-1 flex-col overflow-y-auto">
557
+ <!-- Tree pane: fixed height so it doesn't crowd the detail section -->
558
+ <div class="max-h-64 shrink-0 border-b border-border">
559
+ {@render zarrTree()}
560
+ </div>
561
+ <!-- Detail panel: grows to fill remaining space -->
562
+ <div class="flex flex-1 flex-col">
530
563
  {@render nodeDetails()}
531
564
  </div>
532
- </ResizablePane>
533
- </ResizablePaneGroup>
565
+ </div>
566
+ {/if}
534
567
  {/if}
535
568
  </div>
@@ -18,24 +18,24 @@ let {
18
18
  class="absolute bottom-2 end-2 top-10 z-10 flex w-64 flex-col overflow-hidden rounded bg-card/95 text-card-foreground shadow-lg backdrop-blur-sm sm:w-72"
19
19
  >
20
20
  <div
21
- class="flex items-center justify-between border-b border-zinc-200 px-3 py-2 dark:border-zinc-800"
21
+ class="flex items-center justify-between border-b border-border px-3 py-2"
22
22
  >
23
- <h3 class="text-xs font-medium text-zinc-500 dark:text-zinc-400">Feature Attributes</h3>
23
+ <h3 class="text-xs font-medium text-muted-foreground">Feature Attributes</h3>
24
24
  {#if onClose}
25
25
  <button
26
- class="rounded p-0.5 text-zinc-400 hover:bg-zinc-200 hover:text-zinc-600 dark:hover:bg-zinc-700 dark:hover:text-zinc-300"
26
+ class="rounded p-0.5 text-muted-foreground hover:bg-accent hover:text-accent-foreground"
27
27
  onclick={onClose}
28
28
  >
29
29
  <XIcon class="size-3.5" />
30
30
  </button>
31
31
  {/if}
32
32
  </div>
33
- <div class="flex-1 divide-y divide-zinc-100 overflow-auto dark:divide-zinc-800">
33
+ <div class="flex-1 divide-y divide-border overflow-auto">
34
34
  {#each Object.entries(feature) as [key, value]}
35
35
  <div class="px-3 py-1.5">
36
- <div class="text-[10px] font-medium text-zinc-500 dark:text-zinc-400">{key}</div>
36
+ <div class="text-[10px] font-medium text-muted-foreground">{key}</div>
37
37
  <div
38
- class="break-all text-xs text-zinc-700 dark:text-zinc-300"
38
+ class="break-all text-xs text-foreground"
39
39
  title={formatValue(value)}
40
40
  >
41
41
  {formatValue(value)}
@@ -162,9 +162,9 @@ onDestroy(() => {
162
162
  <div bind:this={containerEl} class="h-full w-full" style="touch-action: none;"></div>
163
163
  <!-- Zoom level indicator — positioned above nav controls -->
164
164
  <div
165
- class="pointer-events-none absolute bottom-[7rem] right-[10px] z-10 flex size-[29px] items-center justify-center rounded-full border border-zinc-300 bg-white shadow-sm dark:border-zinc-600 dark:bg-zinc-800 sm:bottom-[10rem]"
165
+ class="pointer-events-none absolute bottom-[7rem] right-[10px] z-10 flex size-[29px] items-center justify-center rounded-full border border-border bg-background shadow-sm sm:bottom-[10rem]"
166
166
  >
167
- <span class="text-[10px] font-semibold tabular-nums text-zinc-600 dark:text-zinc-300">
167
+ <span class="text-[10px] font-semibold tabular-nums text-foreground">
168
168
  {currentZoom.toFixed(1)}
169
169
  </span>
170
170
  </div>
@@ -1,6 +1,6 @@
1
1
  <script lang="ts">
2
2
  import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
3
- import { formatFileSize } from '@walkthru-earth/objex-utils';
3
+ import { formatFileSize, handleLoadError } from '@walkthru-earth/objex-utils';
4
4
  import type { PMTiles } from 'pmtiles';
5
5
  import { tileIdToZxy } from 'pmtiles';
6
6
  import {
@@ -11,6 +11,7 @@ import {
11
11
  import { t } from '../../../i18n/index.svelte.js';
12
12
  import type { PmtilesMetadata } from '../../../utils/pmtiles';
13
13
  import { highlightCode } from '../../../utils/shiki';
14
+ import { useIsWide } from '../../../utils/media-query.svelte.js';
14
15
 
15
16
  let {
16
17
  metadata,
@@ -22,6 +23,8 @@ let {
22
23
  onOpenInspector?: (z: number, x: number, y: number) => void;
23
24
  } = $props();
24
25
 
26
+ const isWide = useIsWide();
27
+
25
28
  interface ZoomSummary {
26
29
  zoom: number;
27
30
  count: number;
@@ -126,7 +129,7 @@ async function selectZoom(zoom: number) {
126
129
  if (result.length > 5000) break;
127
130
  }
128
131
  } catch (err) {
129
- errorMsg = err instanceof Error ? err.message : String(err);
132
+ errorMsg = handleLoadError(err) ?? '';
130
133
  }
131
134
  }
132
135
  } else {
@@ -165,7 +168,7 @@ const dedupRatio = $derived(
165
168
  {#snippet entryDetails()}
166
169
  {#if selectedEntry}
167
170
  <div
168
- class="shrink-0 border-b border-zinc-200 px-3 py-1.5 text-[11px] font-medium uppercase tracking-wider text-muted-foreground dark:border-zinc-800"
171
+ class="shrink-0 border-b border-border px-3 py-1.5 text-[11px] font-medium uppercase tracking-wider text-muted-foreground"
169
172
  >
170
173
  {t('pmtiles.entryDetails')}
171
174
  </div>
@@ -212,7 +215,7 @@ const dedupRatio = $derived(
212
215
  <div class="flex h-full flex-col overflow-hidden">
213
216
  <!-- Stats grid -->
214
217
  <div
215
- class="shrink-0 border-b border-zinc-200 px-3 py-3 sm:px-4 dark:border-zinc-800"
218
+ class="shrink-0 border-b border-border px-3 py-3 sm:px-4"
216
219
  >
217
220
  <div class="grid grid-cols-2 gap-x-4 gap-y-2 text-xs sm:grid-cols-3 lg:grid-cols-6">
218
221
  <div>
@@ -278,96 +281,119 @@ const dedupRatio = $derived(
278
281
  {/if}
279
282
  </div>
280
283
 
281
- <!-- Column browser (resizable) -->
282
- <ResizablePaneGroup direction="horizontal" class="min-h-0 flex-1">
283
- <!-- Column 1: Zoom levels -->
284
- <ResizablePane defaultSize={28} minSize={15}>
285
- <div class="flex h-full flex-col">
284
+ <!-- Column browser (resizable or stacked) -->
285
+ {#snippet zoomLevels()}
286
+ <div class="flex h-full flex-col">
287
+ <div
288
+ class="shrink-0 border-b border-border px-3 py-1.5 text-[11px] font-medium uppercase tracking-wider text-muted-foreground"
289
+ >
290
+ {t('pmtiles.zoomLevels')}
291
+ </div>
292
+ <div class="flex-1 overflow-auto">
293
+ {#each zoomSummaries as s}
294
+ <button
295
+ class="flex w-full items-center gap-2 px-3 py-1.5 text-xs hover:bg-zinc-100 dark:hover:bg-zinc-800/50"
296
+ class:bg-muted={selectedZoom === s.zoom}
297
+ onclick={() => selectZoom(s.zoom)}
298
+ >
299
+ <span class="w-7 shrink-0 font-mono text-muted-foreground">z{s.zoom}</span>
300
+ <div class="min-w-0 flex-1">
301
+ <div
302
+ class="h-1.5 rounded-full bg-blue-500/60"
303
+ style="width: {Math.max(2, (s.count / maxCount) * 100)}%"
304
+ ></div>
305
+ </div>
306
+ <span class="shrink-0 text-[10px] tabular-nums text-muted-foreground">
307
+ {s.count.toLocaleString()}
308
+ </span>
309
+ <ChevronRightIcon class="size-3 shrink-0 text-muted-foreground" />
310
+ </button>
311
+ {/each}
312
+ </div>
313
+ </div>
314
+ {/snippet}
315
+
316
+ {#snippet zoomEntryList()}
317
+ <div class="flex h-full flex-col">
318
+ {#if selectedZoom !== null}
286
319
  <div
287
- class="shrink-0 border-b border-zinc-200 px-3 py-1.5 text-[11px] font-medium uppercase tracking-wider text-muted-foreground dark:border-zinc-800"
320
+ class="shrink-0 border-b border-border px-3 py-1.5 text-[11px] font-medium uppercase tracking-wider text-muted-foreground"
288
321
  >
289
- {t('pmtiles.zoomLevels')}
322
+ {t('pmtiles.tilesAtZoom').replace('{zoom}', String(selectedZoom))}
323
+ <span class="ms-1 normal-case tracking-normal">({zoomEntries.length.toLocaleString()})</span>
290
324
  </div>
291
325
  <div class="flex-1 overflow-auto">
292
- {#each zoomSummaries as s}
293
- <button
294
- class="flex w-full items-center gap-2 px-3 py-1.5 text-xs hover:bg-zinc-100 dark:hover:bg-zinc-800/50"
295
- class:bg-zinc-100={selectedZoom === s.zoom}
296
- class:dark:bg-zinc-800={selectedZoom === s.zoom}
297
- onclick={() => selectZoom(s.zoom)}
298
- >
299
- <span class="w-7 shrink-0 font-mono text-muted-foreground">z{s.zoom}</span>
300
- <div class="min-w-0 flex-1">
301
- <div
302
- class="h-1.5 rounded-full bg-blue-500/60"
303
- style="width: {Math.max(2, (s.count / maxCount) * 100)}%"
304
- ></div>
305
- </div>
306
- <span class="shrink-0 text-[10px] tabular-nums text-muted-foreground">
307
- {s.count.toLocaleString()}
308
- </span>
309
- <ChevronRightIcon class="size-3 shrink-0 text-muted-foreground" />
310
- </button>
311
- {/each}
326
+ {#if loadingEntries}
327
+ <div class="p-4 text-center text-xs text-muted-foreground">Loading...</div>
328
+ {:else if errorMsg}
329
+ <div class="p-4 text-center text-xs text-destructive">{errorMsg}</div>
330
+ {:else if zoomEntries.length === 0}
331
+ <div class="p-4 text-center text-xs text-muted-foreground">{t('pmtiles.noEntries')}</div>
332
+ {:else}
333
+ {#each zoomEntries as entry}
334
+ <button
335
+ class="flex w-full items-center gap-2 px-3 py-1 text-xs hover:bg-zinc-100 dark:hover:bg-zinc-800/50"
336
+ class:bg-muted={selectedEntry?.tileId === entry.tileId}
337
+ onclick={() => (selectedEntry = entry)}
338
+ >
339
+ <span class="shrink-0 truncate font-mono text-[11px]">
340
+ {entry.z}/{entry.x}/{entry.y}
341
+ </span>
342
+ <span class="ms-auto shrink-0 text-[10px] tabular-nums text-muted-foreground">
343
+ {formatBytes(entry.length)}
344
+ </span>
345
+ <ChevronRightIcon class="size-3 shrink-0 text-muted-foreground" />
346
+ </button>
347
+ {/each}
348
+ {/if}
312
349
  </div>
313
- </div>
314
- </ResizablePane>
350
+ {:else}
351
+ <div class="flex flex-1 items-center justify-center text-xs text-muted-foreground">
352
+ Select a zoom level
353
+ </div>
354
+ {/if}
355
+ </div>
356
+ {/snippet}
315
357
 
316
- <ResizableHandle />
358
+ {#if isWide.value}
359
+ <ResizablePaneGroup direction="horizontal" class="min-h-0 flex-1">
360
+ <!-- Column 1: Zoom levels -->
361
+ <ResizablePane defaultSize={28} minSize={15}>
362
+ {@render zoomLevels()}
363
+ </ResizablePane>
317
364
 
318
- <!-- Column 2: Entries at zoom -->
319
- <ResizablePane defaultSize={42} minSize={20}>
320
- <div class="flex h-full flex-col">
321
- {#if selectedZoom !== null}
322
- <div
323
- class="shrink-0 border-b border-zinc-200 px-3 py-1.5 text-[11px] font-medium uppercase tracking-wider text-muted-foreground dark:border-zinc-800"
324
- >
325
- {t('pmtiles.tilesAtZoom').replace('{zoom}', String(selectedZoom))}
326
- <span class="ms-1 normal-case tracking-normal">({zoomEntries.length.toLocaleString()})</span>
327
- </div>
328
- <div class="flex-1 overflow-auto">
329
- {#if loadingEntries}
330
- <div class="p-4 text-center text-xs text-muted-foreground">Loading...</div>
331
- {:else if errorMsg}
332
- <div class="p-4 text-center text-xs text-red-400">{errorMsg}</div>
333
- {:else if zoomEntries.length === 0}
334
- <div class="p-4 text-center text-xs text-muted-foreground">{t('pmtiles.noEntries')}</div>
335
- {:else}
336
- {#each zoomEntries as entry}
337
- <button
338
- class="flex w-full items-center gap-2 px-3 py-1 text-xs hover:bg-zinc-100 dark:hover:bg-zinc-800/50"
339
- class:bg-zinc-100={selectedEntry?.tileId === entry.tileId}
340
- class:dark:bg-zinc-800={selectedEntry?.tileId === entry.tileId}
341
- onclick={() => (selectedEntry = entry)}
342
- >
343
- <span class="shrink-0 truncate font-mono text-[11px]">
344
- {entry.z}/{entry.x}/{entry.y}
345
- </span>
346
- <span class="ms-auto shrink-0 text-[10px] tabular-nums text-muted-foreground">
347
- {formatBytes(entry.length)}
348
- </span>
349
- <ChevronRightIcon class="size-3 shrink-0 text-muted-foreground" />
350
- </button>
351
- {/each}
352
- {/if}
353
- </div>
354
- {:else}
355
- <div class="flex flex-1 items-center justify-center text-xs text-muted-foreground">
356
- Select a zoom level
357
- </div>
358
- {/if}
359
- </div>
360
- </ResizablePane>
365
+ <ResizableHandle />
361
366
 
362
- <ResizableHandle />
367
+ <!-- Column 2: Entries at zoom -->
368
+ <ResizablePane defaultSize={42} minSize={20}>
369
+ {@render zoomEntryList()}
370
+ </ResizablePane>
363
371
 
364
- <!-- Column 3: Entry details -->
365
- <ResizablePane defaultSize={30} minSize={15}>
366
- <div class="flex h-full flex-col">
372
+ <ResizableHandle />
373
+
374
+ <!-- Column 3: Entry details -->
375
+ <ResizablePane defaultSize={30} minSize={15}>
376
+ <div class="flex h-full flex-col">
377
+ {@render entryDetails()}
378
+ </div>
379
+ </ResizablePane>
380
+ </ResizablePaneGroup>
381
+ {:else}
382
+ <div class="flex min-h-0 flex-1 flex-col overflow-y-auto">
383
+ <!-- Zoom level list: compact fixed height -->
384
+ <div class="max-h-48 shrink-0 border-b border-border">
385
+ {@render zoomLevels()}
386
+ </div>
387
+ <!-- Entries at selected zoom: fixed height -->
388
+ <div class="max-h-56 shrink-0 border-b border-border">
389
+ {@render zoomEntryList()}
390
+ </div>
391
+ <!-- Entry details: grows to fill remaining space -->
392
+ <div class="flex flex-1 flex-col">
367
393
  {@render entryDetails()}
368
394
  </div>
369
- </ResizablePane>
370
- </ResizablePaneGroup>
395
+ </div>
396
+ {/if}
371
397
  </div>
372
398
 
373
399
  <style>