@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
@@ -11,12 +11,14 @@ import {
11
11
  type BandConfig,
12
12
  buildDataTypeLabel,
13
13
  type CogInfo,
14
+ type CustomTileData,
14
15
  clampBounds,
15
16
  cleanupNativeBitmap,
16
17
  createEpsgResolver,
17
18
  DEFAULT_RESCALE,
18
19
  defaultBandConfig,
19
20
  fitCogBounds,
21
+ HISTOGRAM_BIN_COUNT,
20
22
  inspectCogTags,
21
23
  needsCustomPipelineForConfig,
22
24
  normalizeCogGeotiff,
@@ -41,6 +43,8 @@ let showControls = $state(false);
41
43
  let bounds = $state<[number, number, number, number] | undefined>();
42
44
  let cogInfo = $state<CogInfo | null>(null);
43
45
  let bandConfig = $state<BandConfig | null>(null);
46
+ let histogram = $state.raw<Uint32Array | null>(null);
47
+ let histogramTick = $state(0);
44
48
  let rescale = $state<RescaleConfig>({ ...DEFAULT_RESCALE });
45
49
  // Palette-indexed COGs render through the library's Colormap module; a GPU
46
50
  // rescale at that stage is cosmetic and would confuse the legend. Keep the
@@ -58,16 +62,19 @@ let sampleFormatRef = 1;
58
62
  let isTiledRef = true;
59
63
  let clickHandlerRef: ((e: maplibregl.MapMouseEvent) => void) | null = null;
60
64
  let resolvedHttpsUrl: string | null = null;
61
- // True when the library-default uint pipeline will run. LinearRescale only
62
- // operates on already-normalized RGB 0..1, so the slider is meaningful only
63
- // here, and only for non-palette data (palette renders through Colormap).
64
- // `needsCustomPipelineForConfig` only touches read-only tags on the GeoTIFF,
65
- // safe to call outside reactive tracking.
65
+ // LinearRescale operates on a 0..1 scalar. Two cases expose a meaningful
66
+ // slider: (1) the library-default uint RGB pipeline (scales `color.rgb`
67
+ // before presentation), and (2) our custom single-band CPU + GPU Colormap
68
+ // path (scales `color.r` before the ramp sample). Palette COGs hide the
69
+ // slider, the embedded ColorMap tag already bakes the display colors.
70
+ // `needsCustomPipelineForConfig` only touches read-only tags, safe to call
71
+ // outside reactive tracking.
66
72
  const rescaleApplicable = $derived.by(() => {
67
73
  if (!cogInfo || !bandConfig || isPaletteIndexed) return false;
68
74
  const g = geotiffRef;
69
75
  if (!g) return false;
70
- return !needsCustomPipelineForConfig(g, bandConfig);
76
+ if (!needsCustomPipelineForConfig(g, bandConfig)) return true;
77
+ return bandConfig.mode === 'single';
71
78
  });
72
79
  // Tracks whether the camera has already been framed for the current tab.
73
80
  // Prevents fitCogBounds from resetting the user's view when the band/style
@@ -110,6 +117,8 @@ $effect(() => {
110
117
  error = null;
111
118
  cogInfo = null;
112
119
  bandConfig = null;
120
+ histogram = null;
121
+ histogramTick = 0;
113
122
  rescale = { ...DEFAULT_RESCALE };
114
123
  isPaletteIndexed = false;
115
124
  pixelValue = null;
@@ -274,7 +283,10 @@ function buildAndAddLayer(
274
283
 
275
284
  const cogInput = preflightGeotiff ?? resolvedHttpsUrl ?? '';
276
285
 
277
- const layer = new COGLayer({
286
+ // Cast: `onViewportLoad` is forwarded by our pnpm patch to the inner
287
+ // TileLayer, but COGLayer's generated .d.ts does not expose it.
288
+ // biome-ignore lint/suspicious/noExplicitAny: upstream prop not yet in types
289
+ const cogProps: any = {
278
290
  // Stable id per tab so rebuilds on band/style change don't force deck.gl
279
291
  // to treat this as a brand-new layer and drop cached tile state.
280
292
  id: `cog-layer-${tab.id}`,
@@ -283,6 +295,15 @@ function buildAndAddLayer(
283
295
  epsgResolver,
284
296
  signal,
285
297
  ...customProps,
298
+ // COG-native histogram: sum `content.histogram` over tiles currently
299
+ // visible in the viewport. Fires after every pan/zoom settles and
300
+ // reuses deck.gl's tile cache for free, cached tiles still carry
301
+ // their per-tile histogram so no rebake is needed on revisit.
302
+ onViewportLoad: (visibleTiles: unknown) => {
303
+ aggregateVisibleHistogram(
304
+ visibleTiles as ReadonlyArray<{ content?: unknown } | null | undefined>
305
+ );
306
+ },
286
307
  onGeoTIFFLoad: (
287
308
  loadedTiff: GeoTIFF,
288
309
  {
@@ -329,7 +350,8 @@ function buildAndAddLayer(
329
350
  }
330
351
  loading = false;
331
352
  }
332
- });
353
+ };
354
+ const layer = new COGLayer(cogProps);
333
355
 
334
356
  const overlay = new MapboxOverlay({
335
357
  interleaved: false,
@@ -346,10 +368,54 @@ function buildAndAddLayer(
346
368
  map.addControl(overlay as unknown as maplibregl.IControl);
347
369
  }
348
370
 
371
+ // ─── Viewport-scoped histogram aggregation ───────────────────────
372
+
373
+ /**
374
+ * Sum per-tile histograms from tiles currently visible in the viewport. COG
375
+ * pyramid semantics map cleanly: zoomed out → a handful of low-res overview
376
+ * tiles cover the whole scene; zoomed in → only the tiles intersecting the
377
+ * AOI are decoded. deck.gl reuses its tile cache on revisits so each cached
378
+ * tile still carries `content.histogram`, no rebake needed.
379
+ */
380
+ function aggregateVisibleHistogram(
381
+ visibleTiles: ReadonlyArray<{ content?: unknown } | null | undefined>
382
+ ): void {
383
+ if (!visibleTiles || visibleTiles.length === 0) {
384
+ histogram = null;
385
+ histogramTick++;
386
+ return;
387
+ }
388
+ const summed = new Uint32Array(HISTOGRAM_BIN_COUNT);
389
+ let found = false;
390
+ for (const tile of visibleTiles) {
391
+ // COGLayer wraps our baker's return as `{data, forwardTransform,
392
+ // inverseTransform}` in `_getTileData`, so the histogram lives at
393
+ // `content.data.histogram`. MosaicLayer's sub-COGs follow the same
394
+ // shape. Fall back to `content.histogram` for future-proofing if
395
+ // upstream ever stops wrapping.
396
+ const content = tile?.content as
397
+ | { data?: CustomTileData; histogram?: Uint32Array }
398
+ | null
399
+ | undefined;
400
+ const bins = content?.data?.histogram ?? content?.histogram;
401
+ if (!bins || bins.length !== HISTOGRAM_BIN_COUNT) continue;
402
+ for (let i = 0; i < HISTOGRAM_BIN_COUNT; i++) summed[i] += bins[i];
403
+ found = true;
404
+ }
405
+ histogram = found ? summed : null;
406
+ histogramTick++;
407
+ }
408
+
349
409
  // ─── Rebuild layer on band config change ─────────────────────────
350
410
 
351
411
  function handleConfigChange(newConfig: BandConfig) {
352
412
  bandConfig = newConfig;
413
+ // Only the single-band CPU baker emits `onHistogram`. Clear the buffer on
414
+ // every mode/band change so (a) switching back to RGB hides stale bars
415
+ // that the rescale slider would otherwise draw on top of, and (b) picking
416
+ // a different single band starts a fresh distribution.
417
+ histogram = null;
418
+ histogramTick = 0;
353
419
  if (!mapRef || !geotiffRef || !isTiledRef) return;
354
420
 
355
421
  // Remove old overlay
@@ -486,6 +552,7 @@ onDestroy(cleanup);
486
552
  {rescale}
487
553
  rescaleApplicable={rescaleApplicable}
488
554
  onRescaleChange={handleRescaleChange}
555
+ {histogram}
489
556
  />
490
557
  {/if}
491
558
 
@@ -0,0 +1,416 @@
1
+ <script lang="ts">
2
+ import { MapboxOverlay } from '@deck.gl/mapbox';
3
+ import { MultiCOGLayer } from '@developmentseed/deck.gl-geotiff';
4
+ import { DecoderPool } from '@developmentseed/geotiff';
5
+ import type maplibregl from 'maplibre-gl';
6
+ import { onDestroy, untrack } from 'svelte';
7
+ import { t } from '../../i18n/index.svelte.js';
8
+ import { getAdapter } from '../../storage/index.js';
9
+ import { buildProviderBaseUrl, type ProviderId } from '../../storage/providers.js';
10
+ import { connectionStore } from '../../stores/connections.svelte.js';
11
+ import { tabResources } from '../../stores/tab-resources.svelte.js';
12
+ import type { Tab } from '../../types.js';
13
+ import {
14
+ buildBandRenderPipeline,
15
+ clampBounds,
16
+ cleanupNativeBitmap,
17
+ createEpsgResolver,
18
+ fitCogBounds,
19
+ type RescaleConfig
20
+ } from '../../utils/cog.js';
21
+ import {
22
+ type BandMap,
23
+ type BandSlot,
24
+ extractSentinelBandAssets,
25
+ hasRgbBands,
26
+ isStacItem,
27
+ type StacItem,
28
+ type StacRoutableKind
29
+ } from '../../utils/stac.js';
30
+ import { buildHttpsUrlAsync } from '../../utils/url.js';
31
+ import CogControls from './CogControls.svelte';
32
+ import MapContainer from './map/MapContainer.svelte';
33
+
34
+ interface Preset {
35
+ id: string;
36
+ labelKey: string;
37
+ composite: { r: BandSlot; g: BandSlot; b: BandSlot };
38
+ }
39
+
40
+ const PRESETS: Preset[] = [
41
+ {
42
+ id: 'true-color',
43
+ labelKey: 'map.multiCogPreset.trueColor',
44
+ composite: { r: 'red', g: 'green', b: 'blue' }
45
+ },
46
+ {
47
+ id: 'false-color-ir',
48
+ labelKey: 'map.multiCogPreset.falseColorIR',
49
+ composite: { r: 'nir', g: 'red', b: 'green' }
50
+ },
51
+ {
52
+ id: 'swir',
53
+ labelKey: 'map.multiCogPreset.swir',
54
+ composite: { r: 'swir2', g: 'swir1', b: 'red' }
55
+ },
56
+ {
57
+ id: 'vegetation',
58
+ labelKey: 'map.multiCogPreset.vegetation',
59
+ composite: { r: 'nir', g: 'swir1', b: 'red' }
60
+ },
61
+ {
62
+ id: 'agriculture',
63
+ labelKey: 'map.multiCogPreset.agriculture',
64
+ composite: { r: 'swir1', g: 'nir', b: 'blue' }
65
+ }
66
+ ];
67
+
68
+ let { tab, classified }: { tab: Tab; classified?: StacRoutableKind } = $props();
69
+
70
+ let loading = $state(true);
71
+ let error = $state<string | null>(null);
72
+ let showControls = $state(false);
73
+ let bounds = $state<[number, number, number, number] | undefined>();
74
+ let activePresetId = $state<string>('true-color');
75
+ // Sentinel-2 L2A reflectance is scaled uint16 (raw / 10000 = reflectance).
76
+ // After the default uint normalization the slider operates on 0..1, so 0.3
77
+ // keeps typical land surfaces in the visible range without clipping.
78
+ let rescale = $state<RescaleConfig>({ min: 0, max: 0.3 });
79
+
80
+ let bandMap = $state.raw<BandMap>({});
81
+ let abortController = new AbortController();
82
+ let mapRef: maplibregl.Map | null = null;
83
+ let overlayRef: MapboxOverlay | null = null;
84
+ let hasFittedOnce = false;
85
+ let presignCache = new Map<string, Promise<string>>();
86
+ let loadGen = 0;
87
+ let layerVersion = 0;
88
+ let rebuildTimer: number | null = null;
89
+ let lastRebuildAt = 0;
90
+
91
+ // Throttle rebuilds so the rescale slider (oninput, fires per pixel of drag)
92
+ // doesn't spawn N overlapping buildAndAddLayer calls that each addControl a
93
+ // new MapboxOverlay, leaking every overlay but the last.
94
+ const REBUILD_INTERVAL_MS = 750;
95
+
96
+ let pool: DecoderPool | null = new DecoderPool();
97
+ const epsgResolver = createEpsgResolver();
98
+
99
+ const activePreset = $derived(PRESETS.find((p) => p.id === activePresetId) ?? PRESETS[0]);
100
+ const availablePresets = $derived(
101
+ PRESETS.filter((p) => bandMap[p.composite.r] && bandMap[p.composite.g] && bandMap[p.composite.b])
102
+ );
103
+
104
+ $effect(() => {
105
+ if (!tab) return;
106
+ tab.id;
107
+ untrack(() => {
108
+ resetViewer();
109
+ if (mapRef) void loadItem(mapRef);
110
+ });
111
+ });
112
+
113
+ function resetViewer(): void {
114
+ abortController.abort();
115
+ abortController = new AbortController();
116
+ if (rebuildTimer != null) {
117
+ clearTimeout(rebuildTimer);
118
+ rebuildTimer = null;
119
+ }
120
+ lastRebuildAt = 0;
121
+ layerVersion = 0;
122
+ if (mapRef) cleanupNativeBitmap(mapRef);
123
+ if (mapRef && overlayRef) {
124
+ try {
125
+ mapRef.removeControl(overlayRef as unknown as maplibregl.IControl);
126
+ } catch {
127
+ /* already destroyed */
128
+ }
129
+ }
130
+ overlayRef = null;
131
+ bandMap = {};
132
+ presignCache = new Map();
133
+ loading = true;
134
+ error = null;
135
+ bounds = undefined;
136
+ activePresetId = 'true-color';
137
+ rescale = { min: 0, max: 0.3 };
138
+ hasFittedOnce = false;
139
+ showControls = false;
140
+ }
141
+
142
+ function scheduleLayerRebuild(map: maplibregl.Map, signal: AbortSignal): void {
143
+ if (rebuildTimer != null || signal.aborted) return;
144
+ const elapsed = performance.now() - lastRebuildAt;
145
+ const delay = lastRebuildAt === 0 ? 0 : Math.max(0, REBUILD_INTERVAL_MS - elapsed);
146
+ rebuildTimer = window.setTimeout(() => {
147
+ rebuildTimer = null;
148
+ if (signal.aborted) return;
149
+ lastRebuildAt = performance.now();
150
+ void buildAndAddLayer(map, ++layerVersion, signal);
151
+ }, delay);
152
+ }
153
+
154
+ function onMapReady(map: maplibregl.Map): void {
155
+ mapRef = map;
156
+ void loadItem(map);
157
+ }
158
+
159
+ function extractConnectionKey(href: string): string | null {
160
+ const conn = tab.connectionId ? connectionStore.getById(tab.connectionId) : undefined;
161
+ if (!conn) return null;
162
+ const base = buildProviderBaseUrl(
163
+ conn.provider as ProviderId,
164
+ conn.endpoint,
165
+ conn.bucket,
166
+ conn.region
167
+ ).replace(/\/$/, '');
168
+ if (!base) return null;
169
+ const prefix = `${base}/`;
170
+ if (!href.startsWith(prefix)) return null;
171
+ return href.slice(prefix.length);
172
+ }
173
+
174
+ function presignHref(href: string): Promise<string> {
175
+ let cached = presignCache.get(href);
176
+ if (!cached) {
177
+ if (/^https?:\/\//i.test(href)) {
178
+ // Absolute URLs that belong to the tab's own bucket still need SigV4
179
+ // presigning on private buckets — `new URL(rel, base)` strips the
180
+ // base's query string when absolutizing band hrefs, so the signature
181
+ // is lost and the bare URL 403s.
182
+ const key = extractConnectionKey(href);
183
+ if (key !== null) {
184
+ cached = buildHttpsUrlAsync({ ...tab, path: key } as Tab).catch(() => href);
185
+ } else {
186
+ cached = Promise.resolve(href);
187
+ }
188
+ } else {
189
+ cached = buildHttpsUrlAsync({ ...tab, path: href } as Tab).catch(() => href);
190
+ }
191
+ presignCache.set(href, cached);
192
+ }
193
+ return cached;
194
+ }
195
+
196
+ async function loadItem(map: maplibregl.Map): Promise<void> {
197
+ const gen = ++loadGen;
198
+ const signal = abortController.signal;
199
+ try {
200
+ let item: StacItem | null = null;
201
+ if (classified && classified.kind === 'item') {
202
+ item = classified.item;
203
+ } else {
204
+ const adapter = getAdapter(tab.source, tab.connectionId);
205
+ const data = await adapter.read(tab.path, undefined, undefined, signal);
206
+ if (gen !== loadGen || signal.aborted) return;
207
+ const parsed = JSON.parse(new TextDecoder().decode(data));
208
+ if (!isStacItem(parsed)) {
209
+ error = t('map.multiCogMissingBands');
210
+ loading = false;
211
+ return;
212
+ }
213
+ item = parsed as StacItem;
214
+ }
215
+ if (!item) {
216
+ error = t('map.multiCogMissingBands');
217
+ loading = false;
218
+ return;
219
+ }
220
+
221
+ const bands = extractSentinelBandAssets(item);
222
+ if (!hasRgbBands(bands)) {
223
+ error = t('map.multiCogMissingBands');
224
+ loading = false;
225
+ return;
226
+ }
227
+ bandMap = bands;
228
+
229
+ if (Array.isArray(item.bbox) && item.bbox.length >= 4) {
230
+ const clamped = clampBounds({
231
+ west: Number(item.bbox[0]),
232
+ south: Number(item.bbox[1]),
233
+ east: Number(item.bbox[2]),
234
+ north: Number(item.bbox[3])
235
+ });
236
+ bounds = [clamped.west, clamped.south, clamped.east, clamped.north];
237
+ if (!hasFittedOnce) {
238
+ fitCogBounds(map, clamped);
239
+ hasFittedOnce = true;
240
+ }
241
+ }
242
+
243
+ await buildAndAddLayer(map, ++layerVersion, signal);
244
+ } catch (err) {
245
+ if (gen !== loadGen) return;
246
+ if (signal.aborted) return;
247
+ if (err instanceof DOMException && err.name === 'AbortError') return;
248
+ error = err instanceof Error ? err.message : String(err);
249
+ loading = false;
250
+ }
251
+ }
252
+
253
+ async function buildAndAddLayer(
254
+ map: maplibregl.Map,
255
+ version: number,
256
+ signal: AbortSignal
257
+ ): Promise<void> {
258
+ const composite = activePreset.composite;
259
+ const sources: Record<string, { url: string }> = {};
260
+ for (const slot of [composite.r, composite.g, composite.b]) {
261
+ const href = bandMap[slot];
262
+ if (!href) continue;
263
+ const url = await presignHref(href);
264
+ if (version !== layerVersion || signal.aborted) return;
265
+ sources[slot] = { url };
266
+ }
267
+
268
+ const layer = new MultiCOGLayer({
269
+ id: `multicog-${tab.id}-v${version}`,
270
+ sources,
271
+ composite: { r: composite.r, g: composite.g, b: composite.b },
272
+ renderPipeline: buildBandRenderPipeline({ noDataVal: 0, rescale: { ...rescale } }),
273
+ pool: pool ?? undefined,
274
+ epsgResolver,
275
+ signal,
276
+ onGeoTIFFLoad: (_tiffs, { geographicBounds }) => {
277
+ if (version !== layerVersion || signal.aborted) return;
278
+ const clamped = clampBounds(geographicBounds);
279
+ if (!hasFittedOnce) {
280
+ bounds = [clamped.west, clamped.south, clamped.east, clamped.north];
281
+ fitCogBounds(map, clamped);
282
+ hasFittedOnce = true;
283
+ }
284
+ loading = false;
285
+ }
286
+ });
287
+
288
+ if (overlayRef) {
289
+ overlayRef.setProps({ layers: [layer] });
290
+ return;
291
+ }
292
+
293
+ const overlay = new MapboxOverlay({
294
+ interleaved: false,
295
+ layers: [layer],
296
+ onError: (err: Error) => {
297
+ if (signal.aborted) return;
298
+ if (!error) {
299
+ error = err?.message || String(err);
300
+ loading = false;
301
+ }
302
+ }
303
+ });
304
+ overlayRef = overlay;
305
+ map.addControl(overlay as unknown as maplibregl.IControl);
306
+ }
307
+
308
+ function setPreset(id: string): void {
309
+ activePresetId = id;
310
+ if (mapRef) scheduleLayerRebuild(mapRef, abortController.signal);
311
+ }
312
+
313
+ function handleRescaleChange(next: RescaleConfig): void {
314
+ rescale = next;
315
+ if (mapRef) scheduleLayerRebuild(mapRef, abortController.signal);
316
+ }
317
+
318
+ function cleanup(): void {
319
+ abortController.abort();
320
+ if (rebuildTimer != null) {
321
+ clearTimeout(rebuildTimer);
322
+ rebuildTimer = null;
323
+ }
324
+ if (mapRef && overlayRef) {
325
+ try {
326
+ mapRef.removeControl(overlayRef as unknown as maplibregl.IControl);
327
+ } catch {
328
+ /* already removed */
329
+ }
330
+ }
331
+ if (mapRef) cleanupNativeBitmap(mapRef);
332
+ mapRef = null;
333
+ overlayRef = null;
334
+ bandMap = {};
335
+ presignCache.clear();
336
+ const maybeDestroy = pool as unknown as { destroy?: () => void; terminate?: () => void } | null;
337
+ if (maybeDestroy?.destroy) {
338
+ try {
339
+ maybeDestroy.destroy();
340
+ } catch {
341
+ /* ignore */
342
+ }
343
+ } else if (maybeDestroy?.terminate) {
344
+ try {
345
+ maybeDestroy.terminate();
346
+ } catch {
347
+ /* ignore */
348
+ }
349
+ }
350
+ pool = null;
351
+ }
352
+
353
+ $effect(() => {
354
+ const id = tab.id;
355
+ const unregister = tabResources.register(id, cleanup);
356
+ return unregister;
357
+ });
358
+ onDestroy(cleanup);
359
+ </script>
360
+
361
+ <div class="relative flex h-full overflow-hidden">
362
+ <div class="flex-1">
363
+ <MapContainer {onMapReady} {bounds} />
364
+ </div>
365
+
366
+ <div class="pointer-events-none absolute left-2 top-2 z-10 flex flex-col gap-1">
367
+ {#if loading}
368
+ <div class="rounded bg-card/80 px-2 py-1 text-xs text-card-foreground backdrop-blur-sm">
369
+ {t('map.loadingCog')}
370
+ </div>
371
+ {/if}
372
+ {#if error}
373
+ <div class="pointer-events-auto max-w-sm rounded bg-red-900/80 px-2 py-1 text-xs text-red-200">
374
+ {error}
375
+ </div>
376
+ {/if}
377
+ </div>
378
+
379
+ {#if !error && availablePresets.length > 0}
380
+ <div class="absolute right-2 top-2 z-10 flex items-center gap-1">
381
+ <label class="flex items-center gap-1 rounded bg-card/80 px-2 py-1 text-xs text-card-foreground backdrop-blur-sm">
382
+ <span class="text-muted-foreground">{t('map.multiCogPreset.label')}</span>
383
+ <select
384
+ class="rounded border border-border bg-background px-1 py-0.5 text-xs"
385
+ value={activePresetId}
386
+ onchange={(e) => setPreset((e.target as HTMLSelectElement).value)}
387
+ >
388
+ {#each availablePresets as p}
389
+ <option value={p.id}>{t(p.labelKey)}</option>
390
+ {/each}
391
+ </select>
392
+ </label>
393
+ <button
394
+ class="rounded bg-card/80 px-2 py-1 text-xs text-card-foreground backdrop-blur-sm hover:bg-card"
395
+ class:ring-1={showControls}
396
+ class:ring-primary={showControls}
397
+ onclick={() => {
398
+ showControls = !showControls;
399
+ }}
400
+ >
401
+ {t('cog.style')}
402
+ </button>
403
+ </div>
404
+
405
+ {#if showControls}
406
+ <CogControls
407
+ mode="multi"
408
+ bandCount={3}
409
+ onConfigChange={() => {}}
410
+ {rescale}
411
+ rescaleApplicable={true}
412
+ onRescaleChange={handleRescaleChange}
413
+ />
414
+ {/if}
415
+ {/if}
416
+ </div>
@@ -0,0 +1,9 @@
1
+ import type { Tab } from '../../types.js';
2
+ import { type StacRoutableKind } from '../../utils/stac.js';
3
+ type $$ComponentProps = {
4
+ tab: Tab;
5
+ classified?: StacRoutableKind;
6
+ };
7
+ declare const MultiCogViewer: import("svelte").Component<$$ComponentProps, {}, "">;
8
+ type MultiCogViewer = ReturnType<typeof MultiCogViewer>;
9
+ export default MultiCogViewer;
@@ -2,7 +2,7 @@
2
2
  import type { Tab } from '../../types';
3
3
  import { buildHttpsUrlAsync } from '../../utils/url.js';
4
4
 
5
- let { tab }: { tab: Tab } = $props();
5
+ let { tab, variant = 'stac-map' }: { tab: Tab; variant?: 'stac-map' | 'stac-browser' } = $props();
6
6
 
7
7
  let fileUrl = $state('');
8
8
 
@@ -19,9 +19,23 @@ $effect(() => {
19
19
  };
20
20
  });
21
21
 
22
- const iframeSrc = $derived(
23
- fileUrl ? `https://developmentseed.org/stac-map?href=${encodeURIComponent(fileUrl)}` : ''
24
- );
22
+ const iframeSrc = $derived.by(() => {
23
+ if (!fileUrl) return '';
24
+ if (variant === 'stac-browser') {
25
+ // Radiant Earth STAC Browser is a Vue Router SPA. Its
26
+ // `#/external/<url>` route takes the catalog URL verbatim, splitting on
27
+ // `/`. Feeding it an `encodeURIComponent`-encoded URL makes the router
28
+ // hand `https%3A%2F%2F…` to `new URL()`, which reads the collapsed
29
+ // authority as a malformed port and throws
30
+ // `Port "%2F%2Fstorage.googleapis.com%2F…" is not a valid port`.
31
+ // Only escape the `#` character (which would otherwise terminate the
32
+ // hash route) so the rest of the URL flows through intact.
33
+ return `https://radiantearth.github.io/stac-browser/#/external/${fileUrl.replace(/#/g, '%23')}`;
34
+ }
35
+ return `https://developmentseed.org/stac-map?href=${encodeURIComponent(fileUrl)}`;
36
+ });
37
+
38
+ const iframeTitle = $derived(variant === 'stac-browser' ? 'STAC Browser' : 'stac-map');
25
39
  </script>
26
40
 
27
41
  <div class="relative flex h-full overflow-hidden">
@@ -29,7 +43,7 @@ const iframeSrc = $derived(
29
43
  <iframe
30
44
  src={iframeSrc}
31
45
  class="h-full w-full border-0"
32
- title="STAC Map"
46
+ title={iframeTitle}
33
47
  allow="fullscreen"
34
48
  ></iframe>
35
49
  {/if}
@@ -1,6 +1,7 @@
1
1
  import type { Tab } from '../../types';
2
2
  type $$ComponentProps = {
3
3
  tab: Tab;
4
+ variant?: 'stac-map' | 'stac-browser';
4
5
  };
5
6
  declare const StacMapViewer: import("svelte").Component<$$ComponentProps, {}, "">;
6
7
  type StacMapViewer = ReturnType<typeof StacMapViewer>;