@walkthru-earth/objex 1.2.1 → 1.3.1

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 (51) hide show
  1. package/README.md +6 -3
  2. package/dist/components/layout/ConnectionDialog.svelte +35 -3
  3. package/dist/components/layout/Sidebar.svelte +1 -2
  4. package/dist/components/viewers/CodeViewer.svelte +51 -14
  5. package/dist/components/viewers/CodeViewer.svelte.d.ts +11 -1
  6. package/dist/components/viewers/CogControls.svelte +151 -22
  7. package/dist/components/viewers/CogControls.svelte.d.ts +5 -1
  8. package/dist/components/viewers/CogViewer.svelte +75 -8
  9. package/dist/components/viewers/MultiCogViewer.svelte +416 -0
  10. package/dist/components/viewers/MultiCogViewer.svelte.d.ts +9 -0
  11. package/dist/components/viewers/StacMapViewer.svelte +19 -5
  12. package/dist/components/viewers/StacMapViewer.svelte.d.ts +1 -0
  13. package/dist/components/viewers/StacMosaicViewer.svelte +785 -0
  14. package/dist/components/viewers/StacMosaicViewer.svelte.d.ts +9 -0
  15. package/dist/components/viewers/StacTabViewer.svelte +254 -0
  16. package/dist/components/viewers/StacTabViewer.svelte.d.ts +13 -0
  17. package/dist/components/viewers/ViewerRouter.svelte +155 -2
  18. package/dist/components/viewers/ViewerRouter.svelte.d.ts +1 -1
  19. package/dist/components/viewers/ZarrMapViewer.svelte +143 -4
  20. package/dist/components/viewers/ZarrMapViewer.svelte.d.ts +8 -2
  21. package/dist/components/viewers/ZarrViewer.svelte +1 -0
  22. package/dist/i18n/ar.js +27 -0
  23. package/dist/i18n/en.js +27 -0
  24. package/dist/index.d.ts +4 -0
  25. package/dist/index.js +2 -0
  26. package/dist/query/stac-geoparquet.d.ts +31 -0
  27. package/dist/query/stac-geoparquet.js +136 -0
  28. package/dist/stores/connections.svelte.d.ts +38 -23
  29. package/dist/stores/connections.svelte.js +105 -114
  30. package/dist/utils/cog-pure.d.ts +25 -0
  31. package/dist/utils/cog-pure.js +35 -0
  32. package/dist/utils/cog.d.ts +88 -43
  33. package/dist/utils/cog.js +192 -152
  34. package/dist/utils/colormap-sprite.d.ts +39 -0
  35. package/dist/utils/colormap-sprite.js +77 -0
  36. package/dist/utils/connection-identity.d.ts +51 -0
  37. package/dist/utils/connection-identity.js +97 -0
  38. package/dist/utils/host-detection.js +48 -302
  39. package/dist/utils/parquet-metadata.d.ts +7 -1
  40. package/dist/utils/parquet-metadata.js +35 -1
  41. package/dist/utils/stac-geoparquet.d.ts +90 -0
  42. package/dist/utils/stac-geoparquet.js +223 -0
  43. package/dist/utils/stac-hydrate.d.ts +38 -0
  44. package/dist/utils/stac-hydrate.js +243 -0
  45. package/dist/utils/stac.d.ts +136 -0
  46. package/dist/utils/stac.js +176 -0
  47. package/dist/utils/storage-url.d.ts +26 -0
  48. package/dist/utils/storage-url.js +164 -28
  49. package/dist/utils/zarr.d.ts +34 -0
  50. package/dist/utils/zarr.js +94 -0
  51. 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
 
@@ -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">
@@ -168,7 +168,7 @@ async function handleAutoDetection() {
168
168
  }
169
169
 
170
170
  async function loadDemoConnection() {
171
- const id = await connections.save({
171
+ const { id } = await connections.save({
172
172
  name: 'Source Cooperative',
173
173
  provider: 's3',
174
174
  endpoint: '',
@@ -176,7 +176,6 @@ async function loadDemoConnection() {
176
176
  region: 'us-west-2',
177
177
  anonymous: true
178
178
  });
179
- if (!id) return;
180
179
  const conn = connections.getById(id);
181
180
  if (conn) {
182
181
  browser.browse(conn);
@@ -14,15 +14,33 @@ import { extensionToShikiLang, highlightCode } from '../../utils/shiki';
14
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';
@@ -176,9 +194,24 @@ const language = $derived(languageMap[ext] ?? 'Plain Text');
176
194
  /** File types that support native formatting */
177
195
  const canFormat = $derived(['.json', '.sql', '.css', '.html', '.xml'].includes(ext));
178
196
 
179
- // 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.
180
212
  let stacAutoSwitched = false;
181
213
  $effect(() => {
214
+ if (nested) return;
182
215
  if (isStacJson && !stacAutoSwitched && viewMode === 'code' && urlView !== 'code') {
183
216
  stacAutoSwitched = true;
184
217
  viewMode = 'stac-browser';
@@ -327,6 +360,7 @@ async function copyCode() {
327
360
  </script>
328
361
 
329
362
  <div class="flex h-full flex-col">
363
+ {#if !nested}
330
364
  <div
331
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"
332
366
  >
@@ -354,14 +388,16 @@ async function copyCode() {
354
388
  <Badge variant="outline" class="hidden border-emerald-200 text-emerald-600 sm:inline-flex dark:border-emerald-800 dark:text-emerald-300">
355
389
  {t(stacBadgeKey[jsonKind] ?? 'code.stacItem')}
356
390
  </Badge>
357
- <Button
358
- variant={viewMode === 'stac-browser' ? 'default' : 'outline'}
359
- size="sm"
360
- 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' : ''}"
361
- onclick={() => setViewMode('stac-browser')}
362
- >
363
- {viewMode === 'stac-browser' ? t('code.code') : t('code.browseStac')}
364
- </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}
365
401
  {:else if jsonKind === 'kepler'}
366
402
  <Badge variant="outline" class="hidden border-violet-200 text-violet-600 sm:inline-flex dark:border-violet-800 dark:text-violet-300">
367
403
  {t('code.keplerGl')}
@@ -496,6 +532,7 @@ async function copyCode() {
496
532
  </div>
497
533
  </div>
498
534
  </div>
535
+ {/if}
499
536
 
500
537
  {#if viewMode === 'stac-browser' && styleUrl}
501
538
  <div class="flex-1 overflow-hidden">
@@ -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>;