@walkthru-earth/objex 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/README.md +6 -3
  2. package/dist/components/browser/FileTreeSidebar.svelte +1 -1
  3. package/dist/components/layout/ConnectionDialog.svelte +35 -3
  4. package/dist/components/layout/Sidebar.svelte +28 -2
  5. package/dist/components/viewers/ArchiveViewer.svelte +4 -4
  6. package/dist/components/viewers/CodeViewer.svelte +72 -19
  7. package/dist/components/viewers/CodeViewer.svelte.d.ts +11 -1
  8. package/dist/components/viewers/CogControls.svelte +151 -22
  9. package/dist/components/viewers/CogControls.svelte.d.ts +5 -1
  10. package/dist/components/viewers/CogViewer.svelte +45 -10
  11. package/dist/components/viewers/CopcViewer.svelte +20 -2
  12. package/dist/components/viewers/FlatGeobufViewer.svelte +15 -9
  13. package/dist/components/viewers/MultiCogViewer.svelte +416 -0
  14. package/dist/components/viewers/MultiCogViewer.svelte.d.ts +9 -0
  15. package/dist/components/viewers/PmtilesViewer.svelte +2 -2
  16. package/dist/components/viewers/StacMapViewer.svelte +34 -12
  17. package/dist/components/viewers/StacMapViewer.svelte.d.ts +1 -0
  18. package/dist/components/viewers/StacMosaicViewer.svelte +699 -0
  19. package/dist/components/viewers/StacMosaicViewer.svelte.d.ts +9 -0
  20. package/dist/components/viewers/StacTabViewer.svelte +254 -0
  21. package/dist/components/viewers/StacTabViewer.svelte.d.ts +13 -0
  22. package/dist/components/viewers/TableViewer.svelte +50 -21
  23. package/dist/components/viewers/ViewerRouter.svelte +155 -2
  24. package/dist/components/viewers/ViewerRouter.svelte.d.ts +1 -1
  25. package/dist/components/viewers/ZarrMapViewer.svelte +147 -8
  26. package/dist/components/viewers/ZarrMapViewer.svelte.d.ts +8 -2
  27. package/dist/components/viewers/ZarrViewer.svelte +3 -2
  28. package/dist/components/viewers/pmtiles/PmtilesMapView.svelte +0 -1
  29. package/dist/i18n/ar.js +28 -0
  30. package/dist/i18n/en.js +28 -0
  31. package/dist/index.d.ts +4 -0
  32. package/dist/index.js +2 -0
  33. package/dist/query/index.d.ts +1 -1
  34. package/dist/query/index.js +1 -1
  35. package/dist/query/source.d.ts +12 -0
  36. package/dist/query/source.js +25 -8
  37. package/dist/query/stac-geoparquet.d.ts +31 -0
  38. package/dist/query/stac-geoparquet.js +136 -0
  39. package/dist/query/wasm.js +130 -23
  40. package/dist/storage/adapter.d.ts +9 -0
  41. package/dist/storage/adapter.js +13 -1
  42. package/dist/storage/browser-azure.d.ts +1 -1
  43. package/dist/storage/browser-azure.js +4 -0
  44. package/dist/storage/browser-cloud.d.ts +1 -1
  45. package/dist/storage/browser-cloud.js +7 -0
  46. package/dist/storage/presign.d.ts +13 -0
  47. package/dist/storage/presign.js +55 -0
  48. package/dist/storage/providers.d.ts +6 -0
  49. package/dist/storage/providers.js +13 -2
  50. package/dist/stores/browser.svelte.d.ts +2 -0
  51. package/dist/stores/browser.svelte.js +17 -1
  52. package/dist/stores/connections.svelte.d.ts +38 -23
  53. package/dist/stores/connections.svelte.js +105 -114
  54. package/dist/utils/cog.d.ts +80 -18
  55. package/dist/utils/cog.js +187 -125
  56. package/dist/utils/colormap-sprite.d.ts +39 -0
  57. package/dist/utils/colormap-sprite.js +77 -0
  58. package/dist/utils/connection-identity.d.ts +51 -0
  59. package/dist/utils/connection-identity.js +97 -0
  60. package/dist/utils/host-detection.js +48 -302
  61. package/dist/utils/parquet-metadata.d.ts +7 -1
  62. package/dist/utils/parquet-metadata.js +35 -1
  63. package/dist/utils/stac-geoparquet.d.ts +90 -0
  64. package/dist/utils/stac-geoparquet.js +223 -0
  65. package/dist/utils/stac-hydrate.d.ts +38 -0
  66. package/dist/utils/stac-hydrate.js +243 -0
  67. package/dist/utils/stac.d.ts +136 -0
  68. package/dist/utils/stac.js +176 -0
  69. package/dist/utils/storage-url.d.ts +26 -0
  70. package/dist/utils/storage-url.js +164 -28
  71. package/dist/utils/url.d.ts +13 -0
  72. package/dist/utils/url.js +36 -0
  73. package/dist/utils/wkb.js +22 -8
  74. package/dist/utils/zarr.d.ts +34 -0
  75. package/dist/utils/zarr.js +94 -0
  76. package/package.json +14 -13
package/README.md CHANGED
@@ -20,7 +20,7 @@ graph LR
20
20
 
21
21
  - **Browse** cloud storage (S3, GCS, Azure, R2, B2, DigitalOcean, Wasabi, Storj, Hetzner, Contabo, Linode, OVHcloud, MinIO, direct URLs)
22
22
  - **Query** Parquet, CSV, JSONL with SQL (DuckDB-WASM, cancellable queries)
23
- - **Visualize** GeoParquet, GeoJSON, COG, PMTiles, FlatGeobuf, Zarr on maps (MapLibre + deck.gl)
23
+ - **Visualize** GeoParquet, GeoJSON, COG, PMTiles, FlatGeobuf, Zarr (incl. GeoZarr), STAC catalogs, and stac-geoparquet on maps (MapLibre + deck.gl)
24
24
  - **View** 100+ file formats: code (30+ languages), Jupyter notebooks, PDF, 3D models, archives, media
25
25
  - **Share** via URL -- `?url=<storage-url>#<view>` encodes full viewer state
26
26
  - **i18n** -- English + Arabic with automatic RTL layout
@@ -32,7 +32,8 @@ graph LR
32
32
  |----------|---------|
33
33
  | Tabular | Parquet, CSV, TSV, JSONL, NDJSON |
34
34
  | Geo vector | GeoParquet, GeoJSON, Shapefile, GeoPackage, FlatGeobuf |
35
- | Geo raster | COG, PMTiles, Zarr v2/v3 |
35
+ | Geo raster | COG, PMTiles, Zarr v2/v3, GeoZarr |
36
+ | Geo catalog | STAC Item / Collection / Catalog / FeatureCollection (JSON), stac-geoparquet |
36
37
  | Point cloud | COPC, LAZ, LAS |
37
38
  | Notebooks | Jupyter (.ipynb), marimo |
38
39
  | Code | 30+ languages (Python, TS, Rust, Go, SQL...) |
@@ -79,6 +80,8 @@ import {
79
80
  parseWKB,
80
81
  buildGeoArrowTables,
81
82
  readParquetMetadata,
83
+ isStacGeoparquetSchema,
84
+ stacRowToItem,
82
85
  getFileTypeInfo,
83
86
  formatFileSize,
84
87
  generateHexDump,
@@ -105,7 +108,7 @@ Full per-module reference docs: [`packages/objex-utils/docs/`](packages/objex-ut
105
108
  | `./file-icons` | `getFileTypeInfo`, `getDuckDbReadFn`, `getViewerKind` |
106
109
  | `./types` | `FileEntry`, `Connection`, `Tab`, `WriteResult`, `Theme` |
107
110
 
108
- The main export also includes `copyToClipboard`, `handleLoadError`, and shared constants (`WGS84_CODES`, `STORAGE_KEYS`, `DEFAULT_TARGET_CRS`, etc.).
111
+ The main export also includes `copyToClipboard`, `handleLoadError`, the stac-geoparquet helpers (`isStacGeoparquetSchema`, `stacRowToItem`, `flattenStacBbox`, `pickStacPrimaryAsset`, `resolveStacAssetHref`), and shared constants (`WGS84_CODES`, `STORAGE_KEYS`, `DEFAULT_TARGET_CRS`, etc.).
109
112
 
110
113
  ## Quick Start (Development)
111
114
 
@@ -356,7 +356,7 @@ async function expandToPath(path: string) {
356
356
  ? await findNodeAtRoot(accumulatedPath)
357
357
  : await findNodeInParent(parentNode, accumulatedPath);
358
358
 
359
- if (!node || !node.entry.is_dir) break;
359
+ if (!node?.entry.is_dir) break;
360
360
 
361
361
  if (node.children.length === 0) {
362
362
  await loadChildren(node);
@@ -31,7 +31,7 @@ import {
31
31
  type ProviderId,
32
32
  READ_ONLY_HELP
33
33
  } from '../../storage/providers.js';
34
- import { connections } from '../../stores/connections.svelte.js';
34
+ import { connections, DuplicateConnectionError } from '../../stores/connections.svelte.js';
35
35
  import type { Connection, ConnectionConfig } from '../../types.js';
36
36
  import { describeParseResult, looksLikeUrl, parseStorageUrl } from '../../utils/storage-url.js';
37
37
 
@@ -65,6 +65,7 @@ let sasToken = $state('');
65
65
  let saving = $state(false);
66
66
  let testing = $state(false);
67
67
  let testResult = $state<'success' | 'error' | null>(null);
68
+ let duplicateNotice = $state<{ kind: 'merged' | 'blocked'; name: string } | null>(null);
68
69
  let parsedHint = $state<string | null>(null);
69
70
  let endpointAutoFilled = $state(false);
70
71
 
@@ -109,6 +110,7 @@ function resetForm(conn: Connection | null | undefined) {
109
110
  testResult = null;
110
111
  parsedHint = null;
111
112
  endpointAutoFilled = false;
113
+ duplicateNotice = null;
112
114
  }
113
115
 
114
116
  function selectProvider(id: ProviderId) {
@@ -225,17 +227,33 @@ $effect(() => {
225
227
  async function handleSave() {
226
228
  if (!canSave) return;
227
229
  saving = true;
230
+ duplicateNotice = null;
228
231
  try {
229
232
  const config = buildConfig();
230
233
  if (isEditMode && editConnection) {
231
234
  await connections.update(editConnection.id, config);
232
235
  } else {
233
- await connections.save(config);
236
+ const result = await connections.save(config);
237
+ if (result.existed) {
238
+ const existing = connections.getById(result.id);
239
+ duplicateNotice = { kind: 'merged', name: existing?.name ?? config.name };
240
+ // Keep the dialog open briefly so the user sees the notice, then close.
241
+ saving = false;
242
+ setTimeout(() => {
243
+ onSaved();
244
+ open = false;
245
+ }, 1200);
246
+ return;
247
+ }
234
248
  }
235
249
  onSaved();
236
250
  open = false;
237
251
  } catch (err) {
238
- console.error('Failed to save connection:', err);
252
+ if (err instanceof DuplicateConnectionError) {
253
+ duplicateNotice = { kind: 'blocked', name: err.existingName };
254
+ } else {
255
+ console.error('Failed to save connection:', err);
256
+ }
239
257
  } finally {
240
258
  saving = false;
241
259
  }
@@ -532,6 +550,20 @@ async function handleTestConnection() {
532
550
  </details>
533
551
  {/if}
534
552
 
553
+ <!-- Duplicate-connection notice -->
554
+ {#if duplicateNotice}
555
+ <div
556
+ class="flex items-start gap-2 rounded-md border px-3 py-2 text-sm {duplicateNotice.kind === 'merged' ? 'border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-400' : 'border-destructive/30 bg-destructive/10 text-destructive'}"
557
+ >
558
+ <CloudIcon class="mt-0.5 size-4 shrink-0" />
559
+ <span>
560
+ {duplicateNotice.kind === 'merged'
561
+ ? t('connection.duplicateMerged', { name: duplicateNotice.name })
562
+ : t('connection.duplicateBlocked', { name: duplicateNotice.name })}
563
+ </span>
564
+ </div>
565
+ {/if}
566
+
535
567
  <!-- Test Connection Result -->
536
568
  {#if testResult === 'success'}
537
569
  <div class="flex items-center gap-2 rounded-md border border-green-500/30 bg-green-500/10 px-3 py-2 text-sm text-green-700 dark:text-green-400">
@@ -57,6 +57,33 @@ $effect(() => {
57
57
  }
58
58
  });
59
59
 
60
+ // Auto-detected ?url= buckets are saved anonymously (zero-click demo flow).
61
+ // If the first LIST returns 401/403, the bucket is actually private — flip
62
+ // the connection to non-anonymous and open the credential dialog so the
63
+ // user can paste keys instead of seeing a silent failure.
64
+ $effect(() => {
65
+ const conn = browser.authRequired;
66
+ if (!conn) return;
67
+ handleAuthRequired(conn);
68
+ });
69
+
70
+ async function handleAuthRequired(conn: Connection) {
71
+ browser.clearAuthRequired();
72
+ await connections.update(conn.id, {
73
+ name: conn.name,
74
+ provider: conn.provider,
75
+ endpoint: conn.endpoint,
76
+ bucket: conn.bucket,
77
+ region: conn.region,
78
+ anonymous: false,
79
+ authMethod: conn.authMethod,
80
+ rootPrefix: conn.rootPrefix
81
+ });
82
+ const updated = connections.getById(conn.id);
83
+ if (!updated) return;
84
+ await ensureCredentials(updated);
85
+ }
86
+
60
87
  async function handleAutoDetection() {
61
88
  const url = new URL(window.location.href);
62
89
  const rawUrl = url.searchParams.get('url');
@@ -141,7 +168,7 @@ async function handleAutoDetection() {
141
168
  }
142
169
 
143
170
  async function loadDemoConnection() {
144
- const id = await connections.save({
171
+ const { id } = await connections.save({
145
172
  name: 'Source Cooperative',
146
173
  provider: 's3',
147
174
  endpoint: '',
@@ -149,7 +176,6 @@ async function loadDemoConnection() {
149
176
  region: 'us-west-2',
150
177
  anonymous: true
151
178
  });
152
- if (!id) return;
153
179
  const conn = connections.getById(id);
154
180
  if (conn) {
155
181
  browser.browse(conn);
@@ -29,7 +29,7 @@ import {
29
29
  streamZipEntriesFromUrl
30
30
  } from '../../utils/archive';
31
31
  import { formatFileSize } from '../../utils/format';
32
- import { buildHttpsUrl } from '../../utils/url.js';
32
+ import { buildHttpsUrlAsync } from '../../utils/url.js';
33
33
 
34
34
  let { tab }: { tab: Tab } = $props();
35
35
 
@@ -177,7 +177,7 @@ async function loadZip() {
177
177
  const signal = abortController!.signal;
178
178
 
179
179
  if (tab.source === 'remote') {
180
- const url = buildHttpsUrl(tab);
180
+ const url = await buildHttpsUrlAsync(tab);
181
181
  try {
182
182
  scanning = true;
183
183
  for await (const batch of streamZipEntriesFromUrl(url, signal)) {
@@ -208,7 +208,7 @@ async function loadTar() {
208
208
  const signal = abortController!.signal;
209
209
 
210
210
  if (tab.source === 'remote') {
211
- const url = buildHttpsUrl(tab);
211
+ const url = await buildHttpsUrlAsync(tab);
212
212
  try {
213
213
  scanning = true;
214
214
  remoteUrl = url;
@@ -240,7 +240,7 @@ async function loadTarGz() {
240
240
 
241
241
  // For remote URLs: stream-fetch → decompress → parse progressively
242
242
  if (tab.source === 'remote' || tab.source === 'url') {
243
- const url = buildHttpsUrl(tab);
243
+ const url = await buildHttpsUrlAsync(tab);
244
244
  try {
245
245
  scanning = true;
246
246
  const decompressedChunks: Uint8Array[] = [];
@@ -11,18 +11,36 @@ import type { Tab } from '../../types';
11
11
  import { copyToClipboard } from '../../utils/clipboard.js';
12
12
  import { handleLoadError } from '../../utils/error.js';
13
13
  import { extensionToShikiLang, highlightCode } from '../../utils/shiki';
14
- import { buildHttpsUrl } from '../../utils/url.js';
14
+ import { buildHttpsUrl, buildHttpsUrlAsync, canStreamDirectly } from '../../utils/url.js';
15
15
  import { getUrlView, updateUrlView } from '../../utils/url-state.js';
16
16
  import { openZarrTab } from '../../utils/zarr-tab.js';
17
+ import { isStacCatalog, isStacCollection, isStacItem } from '../../utils/stac.js';
18
+
19
+ interface CodeActions {
20
+ toggleFormat: () => Promise<void>;
21
+ copyCode: () => Promise<void>;
22
+ canFormat: boolean;
23
+ formatted: boolean;
24
+ copied: boolean;
25
+ }
17
26
 
18
- let { tab }: { tab: Tab } = $props();
27
+ let {
28
+ tab,
29
+ nested = false,
30
+ wordWrap = $bindable(false),
31
+ actions = $bindable<CodeActions | null>(null)
32
+ }: {
33
+ tab: Tab;
34
+ nested?: boolean;
35
+ wordWrap?: boolean;
36
+ actions?: CodeActions | null;
37
+ } = $props();
19
38
 
20
39
  let abortController: AbortController | null = null;
21
40
  let html = $state('');
22
41
  let rawCode = $state('');
23
42
  let loading = $state(true);
24
43
  let error = $state<string | null>(null);
25
- let wordWrap = $state(false);
26
44
  let copied = $state(false);
27
45
  let formatted = $state(false);
28
46
  const urlView = getUrlView();
@@ -73,9 +91,9 @@ function detectJsonKind(code: string): JsonKind {
73
91
  if (obj && typeof obj === 'object') {
74
92
  if (obj.version === 8 && obj.sources && obj.layers) return 'maplibre-style';
75
93
  if (obj.tilejson && obj.tiles) return 'tilejson';
76
- if (obj.type === 'Catalog' && obj.stac_version) return 'stac-catalog';
77
- if (obj.type === 'Collection' && obj.stac_version) return 'stac-collection';
78
- if (obj.type === 'Feature' && obj.stac_version) return 'stac-item';
94
+ if (isStacCatalog(obj)) return 'stac-catalog';
95
+ if (isStacCollection(obj)) return 'stac-collection';
96
+ if (isStacItem(obj)) return 'stac-item';
79
97
  if (obj.info?.app === 'kepler.gl' && obj.config) return 'kepler';
80
98
  if (obj.zarr_format === 3) return 'zarr-v3';
81
99
  if (obj.zarr_format === 2) return 'zarr-v2';
@@ -96,7 +114,23 @@ const stacBadgeKey = $derived<Record<string, string>>({
96
114
  'stac-collection': 'code.stacCollection',
97
115
  'stac-item': 'code.stacItem'
98
116
  });
99
- const styleUrl = $derived(buildHttpsUrl(tab));
117
+ // Third-party iframes can't route through the storage adapter, so the URL
118
+ // must carry auth. Public/SAS connections resolve synchronously; `signed-s3`
119
+ // must wait for the presign so the iframe never loads a bare `s3://` href.
120
+ let styleUrl = $state('');
121
+ $effect(() => {
122
+ const id = tab.id;
123
+ styleUrl = canStreamDirectly(tab) ? buildHttpsUrl(tab) : '';
124
+ let cancelled = false;
125
+ (async () => {
126
+ const url = await buildHttpsUrlAsync(tab);
127
+ if (cancelled || id !== tab.id) return;
128
+ styleUrl = url;
129
+ })();
130
+ return () => {
131
+ cancelled = true;
132
+ };
133
+ });
100
134
  const stacBrowserSrc = $derived(
101
135
  `https://radiantearth.github.io/stac-browser/#/external/${styleUrl}`
102
136
  );
@@ -160,9 +194,24 @@ const language = $derived(languageMap[ext] ?? 'Plain Text');
160
194
  /** File types that support native formatting */
161
195
  const canFormat = $derived(['.json', '.sql', '.css', '.html', '.xml'].includes(ext));
162
196
 
163
- // Auto-switch to STAC Browser when STAC JSON is detected (unless URL explicitly set #code)
197
+ // Expose imperative actions to the parent so a shared outer toolbar (e.g. the
198
+ // one rendered by StacTabViewer when nested) can invoke Format/Wrap/Copy
199
+ // without duplicating the text state.
200
+ $effect(() => {
201
+ actions = {
202
+ toggleFormat,
203
+ copyCode,
204
+ canFormat,
205
+ formatted,
206
+ copied
207
+ };
208
+ });
209
+
210
+ // Auto-switch to STAC Browser when STAC JSON is detected (unless URL explicitly set #code).
211
+ // Skipped when nested in StacTabViewer since the outer wrapper owns the view toggle.
164
212
  let stacAutoSwitched = false;
165
213
  $effect(() => {
214
+ if (nested) return;
166
215
  if (isStacJson && !stacAutoSwitched && viewMode === 'code' && urlView !== 'code') {
167
216
  stacAutoSwitched = true;
168
217
  viewMode = 'stac-browser';
@@ -311,6 +360,7 @@ async function copyCode() {
311
360
  </script>
312
361
 
313
362
  <div class="flex h-full flex-col">
363
+ {#if !nested}
314
364
  <div
315
365
  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"
316
366
  >
@@ -338,14 +388,16 @@ async function copyCode() {
338
388
  <Badge variant="outline" class="hidden border-emerald-200 text-emerald-600 sm:inline-flex dark:border-emerald-800 dark:text-emerald-300">
339
389
  {t(stacBadgeKey[jsonKind] ?? 'code.stacItem')}
340
390
  </Badge>
341
- <Button
342
- variant={viewMode === 'stac-browser' ? 'default' : 'outline'}
343
- size="sm"
344
- class="h-7 gap-1 px-2 text-xs {viewMode !== 'stac-browser' ? 'border-blue-300 text-blue-600 hover:bg-blue-50 hover:text-blue-700 dark:border-blue-700 dark:text-blue-400 dark:hover:bg-blue-950' : ''}"
345
- onclick={() => setViewMode('stac-browser')}
346
- >
347
- {viewMode === 'stac-browser' ? t('code.code') : t('code.browseStac')}
348
- </Button>
391
+ {#if !nested}
392
+ <Button
393
+ variant={viewMode === 'stac-browser' ? 'default' : 'outline'}
394
+ size="sm"
395
+ class="h-7 gap-1 px-2 text-xs {viewMode !== 'stac-browser' ? 'border-blue-300 text-blue-600 hover:bg-blue-50 hover:text-blue-700 dark:border-blue-700 dark:text-blue-400 dark:hover:bg-blue-950' : ''}"
396
+ onclick={() => setViewMode('stac-browser')}
397
+ >
398
+ {viewMode === 'stac-browser' ? t('code.code') : t('code.browseStac')}
399
+ </Button>
400
+ {/if}
349
401
  {:else if jsonKind === 'kepler'}
350
402
  <Badge variant="outline" class="hidden border-violet-200 text-violet-600 sm:inline-flex dark:border-violet-800 dark:text-violet-300">
351
403
  {t('code.keplerGl')}
@@ -480,8 +532,9 @@ async function copyCode() {
480
532
  </div>
481
533
  </div>
482
534
  </div>
535
+ {/if}
483
536
 
484
- {#if viewMode === 'stac-browser'}
537
+ {#if viewMode === 'stac-browser' && styleUrl}
485
538
  <div class="flex-1 overflow-hidden">
486
539
  <iframe
487
540
  src={stacBrowserSrc}
@@ -490,7 +543,7 @@ async function copyCode() {
490
543
  allow="fullscreen"
491
544
  ></iframe>
492
545
  </div>
493
- {:else if viewMode === 'kepler'}
546
+ {:else if viewMode === 'kepler' && styleUrl}
494
547
  <div class="flex-1 overflow-hidden">
495
548
  <iframe
496
549
  src={keplerSrc}
@@ -499,7 +552,7 @@ async function copyCode() {
499
552
  allow="fullscreen"
500
553
  ></iframe>
501
554
  </div>
502
- {:else if viewMode === 'maputnik'}
555
+ {:else if viewMode === 'maputnik' && styleUrl}
503
556
  <div class="flex-1 overflow-hidden">
504
557
  <iframe
505
558
  src={maputnikSrc}
@@ -1,7 +1,17 @@
1
1
  import type { Tab } from '../../types';
2
+ interface CodeActions {
3
+ toggleFormat: () => Promise<void>;
4
+ copyCode: () => Promise<void>;
5
+ canFormat: boolean;
6
+ formatted: boolean;
7
+ copied: boolean;
8
+ }
2
9
  type $$ComponentProps = {
3
10
  tab: Tab;
11
+ nested?: boolean;
12
+ wordWrap?: boolean;
13
+ actions?: CodeActions | null;
4
14
  };
5
- declare const CodeViewer: import("svelte").Component<$$ComponentProps, {}, "">;
15
+ declare const CodeViewer: import("svelte").Component<$$ComponentProps, {}, "wordWrap" | "actions">;
6
16
  type CodeViewer = ReturnType<typeof CodeViewer>;
7
17
  export default CodeViewer;
@@ -2,12 +2,16 @@
2
2
  import { t } from '../../i18n/index.svelte.js';
3
3
  import {
4
4
  type BandConfig,
5
- COLOR_RAMP_STOPS,
6
5
  type ColorRampId,
7
6
  DEFAULT_RESCALE,
8
- type RescaleConfig,
9
- rampToGradientCss
7
+ type RescaleConfig
10
8
  } from '../../utils/cog.js';
9
+ import {
10
+ COLORMAP_INDEX,
11
+ COLORMAP_NAMES,
12
+ COLORMAP_SPRITE_LAYERS,
13
+ COLORMAP_SPRITE_URL
14
+ } from '../../utils/colormap-sprite.js';
11
15
 
12
16
  let {
13
17
  bandCount,
@@ -15,17 +19,48 @@ let {
15
19
  onConfigChange,
16
20
  rescale,
17
21
  rescaleApplicable,
18
- onRescaleChange
22
+ onRescaleChange,
23
+ histogram = null,
24
+ mode = 'single'
19
25
  }: {
20
26
  bandCount: number;
21
- bandConfig: BandConfig;
27
+ /** Required when `mode === 'single'`, ignored when `mode === 'multi'`. */
28
+ bandConfig?: BandConfig;
22
29
  onConfigChange: (config: BandConfig) => void;
23
30
  rescale: RescaleConfig;
24
31
  rescaleApplicable: boolean;
25
32
  onRescaleChange: (rescale: RescaleConfig) => void;
33
+ /** Optional histogram bins (normalized, single-band only) for the slider overlay. */
34
+ histogram?: Uint32Array | null;
35
+ mode?: 'single' | 'multi';
26
36
  } = $props();
27
37
 
28
- const RAMP_IDS: ColorRampId[] = ['grayscale', 'terrain', 'viridis', 'magma', 'turbo', 'spectral'];
38
+ // ─── Ramp picker state ──────────────────────────────────────────
39
+ // Keep a curated set pinned at the top for familiarity; the full set of
40
+ // 107 is searchable underneath. Pinned names match the old UI exactly so
41
+ // existing muscle memory holds.
42
+ const PINNED_RAMPS: ColorRampId[] = [
43
+ 'gray',
44
+ 'terrain',
45
+ 'viridis',
46
+ 'magma',
47
+ 'turbo',
48
+ 'spectral',
49
+ 'inferno',
50
+ 'plasma',
51
+ 'cividis',
52
+ 'rdylgn'
53
+ ];
54
+
55
+ let rampQuery = $state('');
56
+
57
+ const filteredRamps = $derived.by(() => {
58
+ const q = rampQuery.trim().toLowerCase();
59
+ if (!q) return COLORMAP_NAMES;
60
+ return COLORMAP_NAMES.filter((name) => name.toLowerCase().includes(q));
61
+ });
62
+
63
+ // ─── Helpers ────────────────────────────────────────────────────
29
64
 
30
65
  function bandOptions(count: number): { value: number; label: string }[] {
31
66
  return Array.from({ length: count }, (_, i) => ({
@@ -35,26 +70,53 @@ function bandOptions(count: number): { value: number; label: string }[] {
35
70
  }
36
71
 
37
72
  function setMode(mode: 'rgb' | 'single') {
73
+ if (!bandConfig) return;
38
74
  onConfigChange({ ...bandConfig, mode });
39
75
  }
40
76
 
41
77
  function setBand(key: 'rBand' | 'gBand' | 'bBand' | 'band', value: number) {
78
+ if (!bandConfig) return;
42
79
  onConfigChange({ ...bandConfig, [key]: value });
43
80
  }
44
81
 
45
82
  function setRamp(id: ColorRampId) {
83
+ if (!bandConfig) return;
46
84
  onConfigChange({ ...bandConfig, colorRamp: id });
47
85
  }
48
86
 
87
+ /**
88
+ * CSS `background` declaration that renders one sprite row at the
89
+ * container's full height. Sprite is 256 wide × 107 tall (one 1px row per
90
+ * ramp); we scale it vertically by the target height and offset to land on
91
+ * the requested layer.
92
+ */
93
+ function rampBg(name: ColorRampId, heightPx: number): string {
94
+ const index = COLORMAP_INDEX[name];
95
+ if (index === undefined) return '';
96
+ const totalHeight = COLORMAP_SPRITE_LAYERS * heightPx;
97
+ const yOffset = index * heightPx;
98
+ return [
99
+ `background-image: url("${COLORMAP_SPRITE_URL}")`,
100
+ 'background-repeat: no-repeat',
101
+ `background-size: 100% ${totalHeight}px`,
102
+ `background-position: 0 -${yOffset}px`
103
+ ].join('; ');
104
+ }
105
+
106
+ // ─── Rescale / histogram ────────────────────────────────────────
107
+
108
+ function clamp01(v: number): number {
109
+ return Math.max(0, Math.min(1, v));
110
+ }
111
+
49
112
  function setRescaleMin(value: number) {
50
- // Keep min strictly less than max, clamp to [0, 1].
51
- const clamped = Math.max(0, Math.min(1, value));
113
+ const clamped = clamp01(value);
52
114
  const next = Math.min(clamped, rescale.max - 0.001);
53
115
  onRescaleChange({ min: Number.isFinite(next) ? next : 0, max: rescale.max });
54
116
  }
55
117
 
56
118
  function setRescaleMax(value: number) {
57
- const clamped = Math.max(0, Math.min(1, value));
119
+ const clamped = clamp01(value);
58
120
  const next = Math.max(clamped, rescale.min + 0.001);
59
121
  onRescaleChange({ min: rescale.min, max: Number.isFinite(next) ? next : 1 });
60
122
  }
@@ -62,11 +124,21 @@ function setRescaleMax(value: number) {
62
124
  function resetRescale() {
63
125
  onRescaleChange({ ...DEFAULT_RESCALE });
64
126
  }
127
+
128
+ const histogramBars = $derived.by(() => {
129
+ if (!histogram || histogram.length === 0) return null;
130
+ let max = 0;
131
+ for (const v of histogram) if (v > max) max = v;
132
+ if (max === 0) return null;
133
+ const bins = Array.from(histogram, (count) => count / max);
134
+ return bins;
135
+ });
65
136
  </script>
66
137
 
67
138
  <div
68
- class="absolute right-2 top-10 z-10 w-52 rounded bg-card/90 p-2.5 text-xs text-card-foreground backdrop-blur-sm"
139
+ class="absolute right-2 top-10 z-10 w-60 rounded bg-card/90 p-2.5 text-xs text-card-foreground backdrop-blur-sm"
69
140
  >
141
+ {#if mode === 'single' && bandConfig}
70
142
  <!-- Mode toggle -->
71
143
  <div class="mb-2 flex gap-1">
72
144
  <button
@@ -130,29 +202,57 @@ function resetRescale() {
130
202
 
131
203
  <!-- Color ramp picker -->
132
204
  <div class="space-y-1">
133
- <span class="text-muted-foreground">{t('cog.colorRamp')}</span>
134
- <div class="grid grid-cols-2 gap-1">
135
- {#each RAMP_IDS as id}
205
+ <div class="flex items-center justify-between">
206
+ <span class="text-muted-foreground">{t('cog.colorRamp')}</span>
207
+ <span class="text-[10px] text-muted-foreground tabular-nums">
208
+ {filteredRamps.length}/{COLORMAP_NAMES.length}
209
+ </span>
210
+ </div>
211
+
212
+ <!-- Pinned quick-access (only when no search active) -->
213
+ {#if !rampQuery}
214
+ <div class="grid grid-cols-2 gap-1">
215
+ {#each PINNED_RAMPS as id}
216
+ <button
217
+ class="flex flex-col items-stretch rounded border px-1 py-0.5 transition-colors {bandConfig.colorRamp === id ? 'border-primary bg-muted' : 'border-transparent hover:border-border'}"
218
+ onclick={() => setRamp(id)}
219
+ title={id}
220
+ >
221
+ <div class="h-2.5 w-full rounded-sm" style={rampBg(id, 10)}></div>
222
+ <span class="mt-0.5 text-center text-[10px] capitalize text-muted-foreground">
223
+ {id}
224
+ </span>
225
+ </button>
226
+ {/each}
227
+ </div>
228
+ {/if}
229
+
230
+ <!-- Search + all-ramps scroll list -->
231
+ <input
232
+ type="search"
233
+ placeholder={t('cog.colorRampSearch')}
234
+ class="w-full rounded border border-border bg-background px-1.5 py-0.5 text-[11px]"
235
+ value={rampQuery}
236
+ oninput={(e) => (rampQuery = (e.target as HTMLInputElement).value)}
237
+ />
238
+ <div class="max-h-40 overflow-y-auto rounded border border-border">
239
+ {#each filteredRamps as id}
136
240
  <button
137
- class="flex flex-col items-stretch rounded border px-1 py-0.5 transition-colors {bandConfig.colorRamp === id ? 'border-primary bg-muted' : 'border-transparent'}"
241
+ class="flex w-full items-center gap-2 px-1.5 py-0.5 text-left text-[11px] transition-colors {bandConfig.colorRamp === id ? 'bg-muted' : 'hover:bg-muted/60'}"
138
242
  onclick={() => setRamp(id)}
139
243
  title={id}
140
244
  >
141
- <div
142
- class="h-2.5 w-full rounded-sm"
143
- style="background: {rampToGradientCss(id)}"
144
- ></div>
145
- <span class="mt-0.5 text-center text-[10px] capitalize text-muted-foreground">
146
- {id}
147
- </span>
245
+ <div class="h-2.5 w-14 flex-shrink-0 rounded-sm" style={rampBg(id, 10)}></div>
246
+ <span class="truncate text-muted-foreground">{id}</span>
148
247
  </button>
149
248
  {/each}
150
249
  </div>
151
250
  </div>
152
251
  {/if}
252
+ {/if}
153
253
 
154
254
  {#if rescaleApplicable}
155
- <!-- GPU LinearRescale slider. Default uint pipeline only. -->
255
+ <!-- GPU LinearRescale slider with histogram overlay. -->
156
256
  <div class="mt-2 space-y-1 border-t border-border pt-2">
157
257
  <div class="flex items-center justify-between">
158
258
  <span class="text-muted-foreground">{t('cog.rescale')}</span>
@@ -163,6 +263,35 @@ function resetRescale() {
163
263
  {t('cog.rescaleReset')}
164
264
  </button>
165
265
  </div>
266
+
267
+ <!-- Histogram + range visualization -->
268
+ {#if histogramBars}
269
+ <div class="relative h-8 w-full rounded bg-background/60">
270
+ <!-- Histogram bars -->
271
+ <svg
272
+ viewBox="0 0 100 100"
273
+ preserveAspectRatio="none"
274
+ class="absolute inset-0 h-full w-full"
275
+ aria-hidden="true"
276
+ >
277
+ {#each histogramBars as h, i}
278
+ <rect
279
+ x={(i * 100) / histogramBars.length}
280
+ y={100 - h * 100}
281
+ width={100 / histogramBars.length}
282
+ height={h * 100}
283
+ class="fill-primary/40"
284
+ />
285
+ {/each}
286
+ </svg>
287
+ <!-- Active rescale window -->
288
+ <div
289
+ class="pointer-events-none absolute inset-y-0 border-x border-primary bg-primary/10"
290
+ style="left: {rescale.min * 100}%; right: {(1 - rescale.max) * 100}%;"
291
+ ></div>
292
+ </div>
293
+ {/if}
294
+
166
295
  <div class="flex items-center gap-1.5">
167
296
  <input
168
297
  type="number"
@@ -1,11 +1,15 @@
1
1
  import { type BandConfig, type RescaleConfig } from '../../utils/cog.js';
2
2
  type $$ComponentProps = {
3
3
  bandCount: number;
4
- bandConfig: BandConfig;
4
+ /** Required when `mode === 'single'`, ignored when `mode === 'multi'`. */
5
+ bandConfig?: BandConfig;
5
6
  onConfigChange: (config: BandConfig) => void;
6
7
  rescale: RescaleConfig;
7
8
  rescaleApplicable: boolean;
8
9
  onRescaleChange: (rescale: RescaleConfig) => void;
10
+ /** Optional histogram bins (normalized, single-band only) for the slider overlay. */
11
+ histogram?: Uint32Array | null;
12
+ mode?: 'single' | 'multi';
9
13
  };
10
14
  declare const CogControls: import("svelte").Component<$$ComponentProps, {}, "">;
11
15
  type CogControls = ReturnType<typeof CogControls>;