@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
@@ -27,7 +27,7 @@ import {
27
27
  resolveProj4Def,
28
28
  selectCogPipeline
29
29
  } from '../../utils/cog.js';
30
- import { buildHttpsUrl } from '../../utils/url.js';
30
+ import { buildHttpsUrlAsync } from '../../utils/url.js';
31
31
  import CogControls from './CogControls.svelte';
32
32
  import MapContainer from './map/MapContainer.svelte';
33
33
 
@@ -41,6 +41,8 @@ let showControls = $state(false);
41
41
  let bounds = $state<[number, number, number, number] | undefined>();
42
42
  let cogInfo = $state<CogInfo | null>(null);
43
43
  let bandConfig = $state<BandConfig | null>(null);
44
+ let histogram = $state.raw<Uint32Array | null>(null);
45
+ let histogramTick = $state(0);
44
46
  let rescale = $state<RescaleConfig>({ ...DEFAULT_RESCALE });
45
47
  // Palette-indexed COGs render through the library's Colormap module; a GPU
46
48
  // rescale at that stage is cosmetic and would confuse the legend. Keep the
@@ -57,16 +59,20 @@ let proj4DefRef: string | null = null;
57
59
  let sampleFormatRef = 1;
58
60
  let isTiledRef = true;
59
61
  let clickHandlerRef: ((e: maplibregl.MapMouseEvent) => void) | null = null;
60
- // True when the library-default uint pipeline will run. LinearRescale only
61
- // operates on already-normalized RGB 0..1, so the slider is meaningful only
62
- // here, and only for non-palette data (palette renders through Colormap).
63
- // `needsCustomPipelineForConfig` only touches read-only tags on the GeoTIFF,
64
- // safe to call outside reactive tracking.
62
+ let resolvedHttpsUrl: string | null = null;
63
+ // LinearRescale operates on a 0..1 scalar. Two cases expose a meaningful
64
+ // slider: (1) the library-default uint RGB pipeline (scales `color.rgb`
65
+ // before presentation), and (2) our custom single-band CPU + GPU Colormap
66
+ // path (scales `color.r` before the ramp sample). Palette COGs hide the
67
+ // slider, the embedded ColorMap tag already bakes the display colors.
68
+ // `needsCustomPipelineForConfig` only touches read-only tags, safe to call
69
+ // outside reactive tracking.
65
70
  const rescaleApplicable = $derived.by(() => {
66
71
  if (!cogInfo || !bandConfig || isPaletteIndexed) return false;
67
72
  const g = geotiffRef;
68
73
  if (!g) return false;
69
- return !needsCustomPipelineForConfig(g, bandConfig);
74
+ if (!needsCustomPipelineForConfig(g, bandConfig)) return true;
75
+ return bandConfig.mode === 'single';
70
76
  });
71
77
  // Tracks whether the camera has already been framed for the current tab.
72
78
  // Prevents fitCogBounds from resetting the user's view when the band/style
@@ -104,10 +110,13 @@ $effect(() => {
104
110
  overlayRef = null;
105
111
  geotiffRef = null;
106
112
  proj4DefRef = null;
113
+ resolvedHttpsUrl = null;
107
114
  loading = true;
108
115
  error = null;
109
116
  cogInfo = null;
110
117
  bandConfig = null;
118
+ histogram = null;
119
+ histogramTick = 0;
111
120
  rescale = { ...DEFAULT_RESCALE };
112
121
  isPaletteIndexed = false;
113
122
  pixelValue = null;
@@ -165,7 +174,9 @@ async function loadCog(map: maplibregl.Map) {
165
174
  const signal = abortController.signal;
166
175
 
167
176
  try {
168
- const url = buildHttpsUrl(tab);
177
+ const url = await buildHttpsUrlAsync(tab);
178
+ if (signal.aborted) return;
179
+ resolvedHttpsUrl = url;
169
180
 
170
181
  // Pre-flight: read first IFD to check if tiled (single range request).
171
182
  let isTiled = true;
@@ -187,6 +198,19 @@ async function loadCog(map: maplibregl.Map) {
187
198
  }
188
199
  } catch (preflightErr) {
189
200
  if (signal.aborted) return;
201
+ // `@developmentseed/geotiff` throws "Only tiff supported version:<n>"
202
+ // when the first 4 bytes don't match II*\0 / MM\0* / II+\0 / MM\0+.
203
+ // This happens on files that advertise image/tiff but are corrupt,
204
+ // encrypted, or a different format entirely (GDAL reports "not
205
+ // recognized as being in a supported file format" on the same file).
206
+ // Surface a clear message and bail — COGLayer would re-invoke the
207
+ // same loader and throw the identical error uncaught during update.
208
+ const msg = preflightErr instanceof Error ? preflightErr.message : String(preflightErr);
209
+ if (/Only tiff supported version|not a tiff|Invalid.*magic/i.test(msg)) {
210
+ error = t('map.cogInvalidTiff');
211
+ loading = false;
212
+ return;
213
+ }
190
214
  }
191
215
 
192
216
  // Store refs for pixel inspection and rebuild
@@ -249,13 +273,22 @@ function buildAndAddLayer(
249
273
  // Pick the library-default or one of three custom pipelines. Empty when the
250
274
  // library-default uint path runs unchanged.
251
275
  const customProps = preflightGeotiff
252
- ? selectCogPipeline(preflightGeotiff, { bandConfig, rescale })
276
+ ? selectCogPipeline(preflightGeotiff, {
277
+ bandConfig,
278
+ rescale,
279
+ onHistogram: (bins) => {
280
+ // Copy once so the derived UI sees an immutable snapshot
281
+ // and the accumulating worker buffer is not observed mid-mutation.
282
+ histogram = new Uint32Array(bins);
283
+ histogramTick++;
284
+ }
285
+ })
253
286
  : {};
254
287
 
255
288
  // Apply upstream-bug workarounds in place (overview filter, 4326 bbox clamp).
256
289
  if (preflightGeotiff) normalizeCogGeotiff(preflightGeotiff);
257
290
 
258
- const cogInput = preflightGeotiff ?? buildHttpsUrl(tab);
291
+ const cogInput = preflightGeotiff ?? resolvedHttpsUrl ?? '';
259
292
 
260
293
  const layer = new COGLayer({
261
294
  // Stable id per tab so rebuilds on band/style change don't force deck.gl
@@ -384,6 +417,7 @@ function cleanup() {
384
417
  geotiffRef = null;
385
418
  proj4DefRef = null;
386
419
  pixelValue = null;
420
+ resolvedHttpsUrl = null;
387
421
  }
388
422
 
389
423
  $effect(() => {
@@ -468,6 +502,7 @@ onDestroy(cleanup);
468
502
  {rescale}
469
503
  rescaleApplicable={rescaleApplicable}
470
504
  onRescaleChange={handleRescaleChange}
505
+ {histogram}
471
506
  />
472
507
  {/if}
473
508
 
@@ -1,10 +1,28 @@
1
1
  <script lang="ts">
2
2
  import type { Tab } from '../../types';
3
- import { buildHttpsUrl } from '../../utils/url.js';
3
+ import { buildHttpsUrlAsync } from '../../utils/url.js';
4
4
 
5
5
  let { tab }: { tab: Tab } = $props();
6
6
 
7
- const fileUrl = $derived(tab.source === 'url' ? tab.path : (buildHttpsUrl(tab) ?? ''));
7
+ let fileUrl = $state('');
8
+
9
+ $effect(() => {
10
+ const id = tab.id;
11
+ let cancelled = false;
12
+ (async () => {
13
+ if (tab.source === 'url') {
14
+ fileUrl = tab.path;
15
+ return;
16
+ }
17
+ const url = await buildHttpsUrlAsync(tab);
18
+ if (cancelled || id !== tab.id) return;
19
+ fileUrl = url;
20
+ })();
21
+ return () => {
22
+ cancelled = true;
23
+ };
24
+ });
25
+
8
26
  const viewerUrl = $derived(
9
27
  fileUrl ? `https://viewer.copc.io/?copc=${encodeURIComponent(fileUrl)}` : ''
10
28
  );
@@ -19,7 +19,7 @@ import {
19
19
  hoverCursor,
20
20
  loadDeckModules
21
21
  } from '../../utils/deck.js';
22
- import { buildHttpsUrl } from '../../utils/url.js';
22
+ import { buildHttpsUrlAsync } from '../../utils/url.js';
23
23
  import AttributeTable from './map/AttributeTable.svelte';
24
24
  import MapContainer from './map/MapContainer.svelte';
25
25
 
@@ -55,6 +55,7 @@ let mapReadyPromise: Promise<void> | null = null;
55
55
  // Stored from preview for load-all (skip index)
56
56
  let storedHeader: HeaderMeta | null = null;
57
57
  let storedFeatureOffset = 0;
58
+ let signedUrl: string | null = null;
58
59
 
59
60
  // proj4 converter for reprojecting from source CRS → WGS84
60
61
  let proj4Forward: ((coord: [number, number]) => [number, number]) | null = null;
@@ -217,6 +218,7 @@ function cleanup() {
217
218
  dataVersion = 0;
218
219
  storedHeader = null;
219
220
  storedFeatureOffset = 0;
221
+ signedUrl = null;
220
222
  proj4Forward = null;
221
223
  }
222
224
 
@@ -261,9 +263,14 @@ async function loadFlatGeobuf() {
261
263
  await mapReadyPromise;
262
264
  if (!overlay) return;
263
265
 
266
+ // Sign once per load so header + feature stream share the same signature.
267
+ // Cached across loadAllFeatures() so the "Load all" button doesn't re-sign.
268
+ const url = await buildHttpsUrlAsync(tab);
269
+ signedUrl = url;
270
+
264
271
  // Read header via range requests (fast: 1-2 small requests)
265
272
  // Gets metadata + feature offset to skip the spatial index
266
- await readHeaderWithRangeRequests();
273
+ await readHeaderWithRangeRequests(url);
267
274
 
268
275
  // Set up on-the-fly reprojection if the file uses a non-WGS84 CRS
269
276
  proj4Forward = null;
@@ -298,7 +305,7 @@ async function loadFlatGeobuf() {
298
305
  }
299
306
 
300
307
  // Stream features (skips index if header was read, else sequential)
301
- await streamFeatures(settings.featureLimit);
308
+ await streamFeatures(url, settings.featureLimit);
302
309
  } catch (err) {
303
310
  console.error('[FGB]', 'loadFlatGeobuf error:', err);
304
311
  if (err instanceof DOMException && err.name === 'AbortError') return;
@@ -314,9 +321,7 @@ async function loadFlatGeobuf() {
314
321
  * Read header via range requests (fast: 1-2 small requests).
315
322
  * Stores header + feature offset for the composite stream approach.
316
323
  */
317
- async function readHeaderWithRangeRequests(): Promise<boolean> {
318
- const url = buildHttpsUrl(tab);
319
-
324
+ async function readHeaderWithRangeRequests(url: string): Promise<boolean> {
320
325
  let reader: HttpReader;
321
326
  try {
322
327
  reader = await HttpReader.open(url, false);
@@ -357,7 +362,9 @@ async function loadAllFeatures() {
357
362
  try {
358
363
  features = [];
359
364
  featureCount = 0;
360
- await streamFeatures();
365
+ const url = signedUrl ?? (await buildHttpsUrlAsync(tab));
366
+ signedUrl = url;
367
+ await streamFeatures(url);
361
368
  } catch (err) {
362
369
  console.error('[FGB]', 'loadAllFeatures error:', err);
363
370
  if (err instanceof DOMException && err.name === 'AbortError') return;
@@ -372,10 +379,9 @@ async function loadAllFeatures() {
372
379
  * Stream features sequentially.
373
380
  * If storedHeader is available, skips the index with a Range request + composite stream.
374
381
  */
375
- async function streamFeatures(limit?: number) {
382
+ async function streamFeatures(url: string, limit?: number) {
376
383
  const ac = new AbortController();
377
384
  abortController = ac;
378
- const url = buildHttpsUrl(tab);
379
385
  const t0 = performance.now();
380
386
 
381
387
  let iter: AsyncGenerator;
@@ -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;
@@ -11,7 +11,7 @@ import { t } from '../../i18n/index.svelte.js';
11
11
  import { tabResources } from '../../stores/tab-resources.svelte.js';
12
12
  import type { Tab } from '../../types';
13
13
  import { loadPmtiles, type PmtilesMetadata } from '../../utils/pmtiles';
14
- import { buildHttpsUrl } from '../../utils/url.js';
14
+ import { buildHttpsUrlAsync } from '../../utils/url.js';
15
15
  import { getUrlView, updateUrlView } from '../../utils/url-state.js';
16
16
 
17
17
  let { tab }: { tab: Tab } = $props();
@@ -73,7 +73,7 @@ async function load() {
73
73
  error = null;
74
74
 
75
75
  try {
76
- pmtilesUrl = buildHttpsUrl(tab);
76
+ pmtilesUrl = await buildHttpsUrlAsync(tab);
77
77
  const result = await loadPmtiles(pmtilesUrl);
78
78
  pmtilesInstance = result.pmtiles;
79
79
  metadata = result.metadata;