@walkthru-earth/objex 1.2.1 → 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 (49) 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 +24 -7
  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 +11 -5
  12. package/dist/components/viewers/StacMapViewer.svelte.d.ts +1 -0
  13. package/dist/components/viewers/StacMosaicViewer.svelte +699 -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.d.ts +80 -18
  31. package/dist/utils/cog.js +187 -125
  32. package/dist/utils/colormap-sprite.d.ts +39 -0
  33. package/dist/utils/colormap-sprite.js +77 -0
  34. package/dist/utils/connection-identity.d.ts +51 -0
  35. package/dist/utils/connection-identity.js +97 -0
  36. package/dist/utils/host-detection.js +48 -302
  37. package/dist/utils/parquet-metadata.d.ts +7 -1
  38. package/dist/utils/parquet-metadata.js +35 -1
  39. package/dist/utils/stac-geoparquet.d.ts +90 -0
  40. package/dist/utils/stac-geoparquet.js +223 -0
  41. package/dist/utils/stac-hydrate.d.ts +38 -0
  42. package/dist/utils/stac-hydrate.js +243 -0
  43. package/dist/utils/stac.d.ts +136 -0
  44. package/dist/utils/stac.js +176 -0
  45. package/dist/utils/storage-url.d.ts +26 -0
  46. package/dist/utils/storage-url.js +164 -28
  47. package/dist/utils/zarr.d.ts +34 -0
  48. package/dist/utils/zarr.js +94 -0
  49. package/package.json +14 -13
@@ -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,15 @@ $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
+ return `https://radiantearth.github.io/stac-browser/#/external/${encodeURIComponent(fileUrl)}`;
26
+ }
27
+ return `https://developmentseed.org/stac-map?href=${encodeURIComponent(fileUrl)}`;
28
+ });
29
+
30
+ const iframeTitle = $derived(variant === 'stac-browser' ? 'STAC Browser' : 'stac-map');
25
31
  </script>
26
32
 
27
33
  <div class="relative flex h-full overflow-hidden">
@@ -29,7 +35,7 @@ const iframeSrc = $derived(
29
35
  <iframe
30
36
  src={iframeSrc}
31
37
  class="h-full w-full border-0"
32
- title="STAC Map"
38
+ title={iframeTitle}
33
39
  allow="fullscreen"
34
40
  ></iframe>
35
41
  {/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>;