@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
@@ -0,0 +1,699 @@
1
+ <script lang="ts">
2
+ import { MapboxOverlay } from '@deck.gl/mapbox';
3
+ import { COGLayer, MosaicLayer } from '@developmentseed/deck.gl-geotiff';
4
+ import { DecoderPool, GeoTIFF } 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 { queryStacGeoparquetFeatureCollection } from '../../query/stac-geoparquet.js';
9
+ import { getAdapter } from '../../storage/index.js';
10
+ import { buildProviderBaseUrl, type ProviderId } from '../../storage/providers.js';
11
+ import { connectionStore } from '../../stores/connections.svelte.js';
12
+ import { tabResources } from '../../stores/tab-resources.svelte.js';
13
+ import type { Tab } from '../../types.js';
14
+ import {
15
+ type BandConfig,
16
+ buildDataTypeLabel,
17
+ clampBounds,
18
+ cleanupNativeBitmap,
19
+ createEpsgResolver,
20
+ DEFAULT_RESCALE,
21
+ defaultBandConfig,
22
+ fitCogBounds,
23
+ normalizeCogGeotiff,
24
+ type PixelValue,
25
+ type RescaleConfig,
26
+ readPixelAtLngLat,
27
+ resolveProj4Def,
28
+ selectCogPipeline
29
+ } from '../../utils/cog.js';
30
+ import {
31
+ buildMosaicSourceMeta,
32
+ classifyStac,
33
+ type MosaicSourceMeta,
34
+ type StacRoutableKind
35
+ } from '../../utils/stac.js';
36
+ import { hydrateStacItems } from '../../utils/stac-hydrate.js';
37
+ import { buildHttpsUrlAsync } from '../../utils/url.js';
38
+ import CogControls from './CogControls.svelte';
39
+ import MapContainer from './map/MapContainer.svelte';
40
+
41
+ let { tab, classified }: { tab: Tab; classified?: StacRoutableKind } = $props();
42
+
43
+ let loading = $state(true);
44
+ let error = $state<string | null>(null);
45
+ let showControls = $state(false);
46
+ let showInfo = $state(false);
47
+ let sourceCount = $state(0);
48
+ let bounds = $state<[number, number, number, number] | undefined>();
49
+ let bandConfig = $state<BandConfig | null>(null);
50
+ let histogram = $state.raw<Uint32Array | null>(null);
51
+ let rescale = $state<RescaleConfig>({ ...DEFAULT_RESCALE });
52
+ let detectedBandCount = $state<number>(3);
53
+ let detectedDataType = $state<string>('');
54
+ let probedBandCount = false;
55
+
56
+ // ─── Pixel inspection ───────────────────────────────────────────
57
+ let pixelValue = $state<PixelValue | null>(null);
58
+ let pixelSourceId = $state<string | null>(null);
59
+ let inspecting = $state(false);
60
+ let clickHandlerRef: ((e: maplibregl.MapMouseEvent) => void) | null = null;
61
+ // Reuse GeoTIFFs resolved by MosaicLayer's `getSource` callback so click
62
+ // handlers don't trigger a second HTTP fetch. Keyed by `source.id`.
63
+ let geotiffCache = new Map<string, Promise<GeoTIFF>>();
64
+
65
+ let abortController = new AbortController();
66
+ let mapRef: maplibregl.Map | null = null;
67
+ let overlayRef: MapboxOverlay | null = null;
68
+ let itemsRef = $state.raw<MosaicSourceMeta[]>([]);
69
+ let hasFittedOnce = false;
70
+ let rebuildTimer: number | null = null;
71
+ let lastRebuildAt = 0;
72
+ let layerVersion = 0;
73
+ let presignCache = new Map<string, Promise<string>>();
74
+ let loadGen = 0;
75
+
76
+ // MosaicLayer builds a Flatbush spatial index at construction; deck.gl reuses
77
+ // the existing internal tileset when only props change, so the index never
78
+ // picks up new sources. Minimum interval between rebuilds + version-bumped id
79
+ // forces deck.gl to mount a fresh MosaicLayer with a rebuilt index, at the
80
+ // cost of discarding the tile cache. 750ms balances progressive feedback
81
+ // against cache churn.
82
+ const REBUILD_INTERVAL_MS = 750;
83
+
84
+ let pool: DecoderPool | null = new DecoderPool();
85
+ const epsgResolver = createEpsgResolver();
86
+
87
+ $effect(() => {
88
+ if (!tab) return;
89
+ tab.id;
90
+ untrack(() => {
91
+ resetViewer();
92
+ if (mapRef) void loadMosaic(mapRef);
93
+ });
94
+ });
95
+
96
+ function resetViewer(): void {
97
+ abortController.abort();
98
+ abortController = new AbortController();
99
+ if (rebuildTimer != null) {
100
+ clearTimeout(rebuildTimer);
101
+ rebuildTimer = null;
102
+ }
103
+ lastRebuildAt = 0;
104
+ layerVersion = 0;
105
+ if (mapRef) cleanupNativeBitmap(mapRef);
106
+ if (mapRef && overlayRef) {
107
+ try {
108
+ mapRef.removeControl(overlayRef as unknown as maplibregl.IControl);
109
+ } catch {
110
+ /* map already destroyed */
111
+ }
112
+ }
113
+ overlayRef = null;
114
+ itemsRef = [];
115
+ presignCache = new Map();
116
+ geotiffCache = new Map();
117
+ loading = true;
118
+ error = null;
119
+ sourceCount = 0;
120
+ bounds = undefined;
121
+ bandConfig = null;
122
+ histogram = null;
123
+ rescale = { ...DEFAULT_RESCALE };
124
+ hasFittedOnce = false;
125
+ showControls = false;
126
+ showInfo = false;
127
+ detectedBandCount = 3;
128
+ detectedDataType = '';
129
+ probedBandCount = false;
130
+ pixelValue = null;
131
+ pixelSourceId = null;
132
+ inspecting = false;
133
+ if (mapRef) removeClickHandler();
134
+ }
135
+
136
+ function removeClickHandler(): void {
137
+ if (mapRef && clickHandlerRef) {
138
+ mapRef.off('click', clickHandlerRef);
139
+ clickHandlerRef = null;
140
+ }
141
+ }
142
+
143
+ function setupClickHandler(map: maplibregl.Map): void {
144
+ removeClickHandler();
145
+ clickHandlerRef = async (e: maplibregl.MapMouseEvent) => {
146
+ // Find the topmost source whose bbox contains the click. `itemsRef`
147
+ // is z-ordered by the mosaic so the last matching entry wins, matching
148
+ // MosaicLayer's tile compositing order.
149
+ const lng = e.lngLat.lng;
150
+ const lat = e.lngLat.lat;
151
+ const items = itemsRef;
152
+ let hit: MosaicSourceMeta | undefined;
153
+ for (let i = items.length - 1; i >= 0; i--) {
154
+ const [w, s, east, n] = items[i].bbox;
155
+ if (lng >= w && lng <= east && lat >= s && lat <= n) {
156
+ hit = items[i];
157
+ break;
158
+ }
159
+ }
160
+ if (!hit) {
161
+ pixelValue = null;
162
+ pixelSourceId = null;
163
+ return;
164
+ }
165
+ inspecting = true;
166
+ try {
167
+ // Pull from cache; if absent (user clicked before any tile fetched
168
+ // this source), kick off a fresh load and cache it for later.
169
+ let geotiffPromise = geotiffCache.get(hit.id);
170
+ if (!geotiffPromise) {
171
+ geotiffPromise = (async () => {
172
+ const url = await presignHref(hit.href);
173
+ const g = await GeoTIFF.fromUrl(url);
174
+ normalizeCogGeotiff(g);
175
+ return g;
176
+ })();
177
+ geotiffCache.set(hit.id, geotiffPromise);
178
+ }
179
+ const geotiff = await geotiffPromise;
180
+ const proj4Def = await resolveProj4Def(geotiff.crs, abortController.signal);
181
+ const result = await readPixelAtLngLat(
182
+ geotiff,
183
+ lng,
184
+ lat,
185
+ proj4Def,
186
+ pool,
187
+ abortController.signal
188
+ );
189
+ pixelValue = result;
190
+ pixelSourceId = hit.id;
191
+ } catch {
192
+ pixelValue = null;
193
+ pixelSourceId = null;
194
+ } finally {
195
+ inspecting = false;
196
+ }
197
+ };
198
+ map.on('click', clickHandlerRef);
199
+ }
200
+
201
+ function onMapReady(map: maplibregl.Map): void {
202
+ mapRef = map;
203
+ setupClickHandler(map);
204
+ void loadMosaic(map);
205
+ }
206
+
207
+ function extractConnectionKey(href: string): string | null {
208
+ const conn = tab.connectionId ? connectionStore.getById(tab.connectionId) : undefined;
209
+ if (!conn) return null;
210
+ const base = buildProviderBaseUrl(
211
+ conn.provider as ProviderId,
212
+ conn.endpoint,
213
+ conn.bucket,
214
+ conn.region
215
+ ).replace(/\/$/, '');
216
+ if (!base) return null;
217
+ const prefix = `${base}/`;
218
+ if (!href.startsWith(prefix)) return null;
219
+ return href.slice(prefix.length);
220
+ }
221
+
222
+ function presignHref(href: string): Promise<string> {
223
+ let cached = presignCache.get(href);
224
+ if (!cached) {
225
+ if (/^https?:\/\//i.test(href)) {
226
+ // Absolute URLs that belong to the tab's own bucket still need SigV4
227
+ // presigning on private buckets (GCS/S3) — `new URL(rel, base)` strips
228
+ // the base's query string when absolutizing asset hrefs, so the
229
+ // signature is lost and the bare URL 403s.
230
+ const key = extractConnectionKey(href);
231
+ if (key !== null) {
232
+ cached = buildHttpsUrlAsync({ ...tab, path: key } as Tab).catch(() => href);
233
+ } else {
234
+ cached = Promise.resolve(href);
235
+ }
236
+ } else {
237
+ cached = buildHttpsUrlAsync({ ...tab, path: href } as Tab).catch(() => href);
238
+ }
239
+ presignCache.set(href, cached);
240
+ }
241
+ return cached;
242
+ }
243
+
244
+ function extendBounds(
245
+ current: [number, number, number, number] | null,
246
+ items: MosaicSourceMeta[]
247
+ ): [number, number, number, number] | null {
248
+ if (items.length === 0) return current;
249
+ let [w, s, e, n] = current ?? items[0].bbox;
250
+ for (const item of items) {
251
+ w = Math.min(w, item.bbox[0]);
252
+ s = Math.min(s, item.bbox[1]);
253
+ e = Math.max(e, item.bbox[2]);
254
+ n = Math.max(n, item.bbox[3]);
255
+ }
256
+ const clamped = clampBounds({ west: w, south: s, east: e, north: n });
257
+ return [clamped.west, clamped.south, clamped.east, clamped.north];
258
+ }
259
+
260
+ function scheduleLayerRebuild(map: maplibregl.Map, signal: AbortSignal): void {
261
+ if (rebuildTimer != null || signal.aborted) return;
262
+ const elapsed = performance.now() - lastRebuildAt;
263
+ const delay = lastRebuildAt === 0 ? 0 : Math.max(0, REBUILD_INTERVAL_MS - elapsed);
264
+ rebuildTimer = window.setTimeout(() => {
265
+ rebuildTimer = null;
266
+ if (signal.aborted) return;
267
+ lastRebuildAt = performance.now();
268
+ buildOrUpdateLayer(map, signal);
269
+ }, delay);
270
+ }
271
+
272
+ function flushPendingRebuild(map: maplibregl.Map, signal: AbortSignal): void {
273
+ if (rebuildTimer != null) {
274
+ clearTimeout(rebuildTimer);
275
+ rebuildTimer = null;
276
+ }
277
+ if (signal.aborted) return;
278
+ lastRebuildAt = performance.now();
279
+ buildOrUpdateLayer(map, signal);
280
+ }
281
+
282
+ async function loadMosaic(map: maplibregl.Map): Promise<void> {
283
+ const gen = ++loadGen;
284
+ const signal = abortController.signal;
285
+ try {
286
+ const adapter = getAdapter(tab.source, tab.connectionId);
287
+ const ext = (tab.extension ?? '').toLowerCase();
288
+
289
+ // stac-geoparquet path: DuckDB materializes the full FeatureCollection
290
+ // in one query, so hydration is a single batch (no link walking).
291
+ if (ext === 'parquet' || ext === 'geoparquet') {
292
+ const fc = await queryStacGeoparquetFeatureCollection(tab, tab.connectionId ?? '', {
293
+ signal,
294
+ limit: 2000
295
+ });
296
+ if (gen !== loadGen || signal.aborted) return;
297
+ if (fc.features.length === 0) {
298
+ error = t('map.mosaicEmpty');
299
+ loading = false;
300
+ return;
301
+ }
302
+ await ingestParquetFeatures(map, fc.features, signal, gen);
303
+ return;
304
+ }
305
+
306
+ let kind: StacRoutableKind;
307
+ if (classified && classified.kind !== 'none') {
308
+ kind = classified;
309
+ } else {
310
+ const data = await adapter.read(tab.path, undefined, undefined, signal);
311
+ if (gen !== loadGen || signal.aborted) return;
312
+ const parsed = JSON.parse(new TextDecoder().decode(data));
313
+ kind = classifyStac(parsed);
314
+ }
315
+ if (kind.kind === 'none') {
316
+ error = t('map.mosaicEmpty');
317
+ loading = false;
318
+ return;
319
+ }
320
+
321
+ let runningBounds: [number, number, number, number] | null = null;
322
+ // Resolve tab.path to an absolute URL so relative hrefs in the manifest
323
+ // (e.g. `./item.json`) resolve against the real parent directory. For
324
+ // bucket-connection tabs, tab.path is a bucket-relative key and would not
325
+ // be a valid URL base.
326
+ const baseHref = await buildHttpsUrlAsync(tab);
327
+ if (gen !== loadGen || signal.aborted) return;
328
+
329
+ await hydrateStacItems(kind, baseHref, adapter, {
330
+ signal,
331
+ concurrency: 12,
332
+ limit: 2000,
333
+ urlToKey: extractConnectionKey,
334
+ onBatch: (batch) => {
335
+ if (gen !== loadGen || signal.aborted) return;
336
+ const accepted: MosaicSourceMeta[] = [];
337
+ for (const item of batch) {
338
+ const normalized = buildMosaicSourceMeta(item);
339
+ if (normalized) accepted.push(normalized);
340
+ }
341
+ if (accepted.length === 0) return;
342
+
343
+ for (const src of accepted) presignHref(src.href);
344
+
345
+ itemsRef = [...itemsRef, ...accepted];
346
+ sourceCount = itemsRef.length;
347
+
348
+ runningBounds = extendBounds(runningBounds, accepted);
349
+ // Only fit the camera once, on the first batch with a valid bbox.
350
+ // Re-assigning `bounds` on later batches would cause MapContainer
351
+ // to re-fly every 12-item batch, making the map unusable until
352
+ // hydration completes.
353
+ if (!hasFittedOnce && runningBounds) {
354
+ bounds = runningBounds;
355
+ fitCogBounds(map, {
356
+ west: runningBounds[0],
357
+ south: runningBounds[1],
358
+ east: runningBounds[2],
359
+ north: runningBounds[3]
360
+ });
361
+ hasFittedOnce = true;
362
+ }
363
+
364
+ if (!bandConfig) bandConfig = defaultBandConfig(detectedBandCount, 1);
365
+ scheduleLayerRebuild(map, signal);
366
+ loading = false;
367
+ }
368
+ });
369
+
370
+ if (gen !== loadGen) return;
371
+ if (itemsRef.length === 0 && !signal.aborted) {
372
+ error = t('map.mosaicNoAssets');
373
+ loading = false;
374
+ return;
375
+ }
376
+ // Final rebuild once hydration completes so every source is in the
377
+ // index, even if the last batch landed inside the throttle window.
378
+ if (!signal.aborted) flushPendingRebuild(map, signal);
379
+ } catch (err) {
380
+ if (gen !== loadGen) return;
381
+ if (signal.aborted) return;
382
+ if (err instanceof DOMException && err.name === 'AbortError') return;
383
+ error = err instanceof Error ? err.message : String(err);
384
+ loading = false;
385
+ }
386
+ }
387
+
388
+ /** Single-batch ingestion path for stac-geoparquet (already materialized). */
389
+ async function ingestParquetFeatures(
390
+ map: maplibregl.Map,
391
+ features: import('../../utils/stac.js').StacItem[],
392
+ signal: AbortSignal,
393
+ gen: number
394
+ ): Promise<void> {
395
+ const accepted: MosaicSourceMeta[] = [];
396
+ for (const item of features) {
397
+ const normalized = buildMosaicSourceMeta(item);
398
+ if (normalized) accepted.push(normalized);
399
+ }
400
+ if (gen !== loadGen || signal.aborted) return;
401
+ if (accepted.length === 0) {
402
+ error = t('map.mosaicNoAssets');
403
+ loading = false;
404
+ return;
405
+ }
406
+ for (const src of accepted) presignHref(src.href);
407
+
408
+ itemsRef = accepted;
409
+ sourceCount = itemsRef.length;
410
+
411
+ let runningBounds: [number, number, number, number] | null = null;
412
+ runningBounds = extendBounds(runningBounds, accepted);
413
+ if (runningBounds) {
414
+ bounds = runningBounds;
415
+ fitCogBounds(map, {
416
+ west: runningBounds[0],
417
+ south: runningBounds[1],
418
+ east: runningBounds[2],
419
+ north: runningBounds[3]
420
+ });
421
+ hasFittedOnce = true;
422
+ }
423
+
424
+ if (!bandConfig) bandConfig = defaultBandConfig(3, 1);
425
+ loading = false;
426
+ flushPendingRebuild(map, signal);
427
+ }
428
+
429
+ function buildOrUpdateLayer(map: maplibregl.Map, signal: AbortSignal): void {
430
+ const snapshotSources = $state.snapshot(itemsRef) as MosaicSourceMeta[];
431
+ const bc = bandConfig ? { ...bandConfig } : null;
432
+ const rs = { ...rescale };
433
+
434
+ const version = ++layerVersion;
435
+ const layer = new MosaicLayer<MosaicSourceMeta, GeoTIFF>({
436
+ id: `mosaic-${tab.id}-v${version}`,
437
+ sources: snapshotSources,
438
+ maxCacheSize: 8,
439
+ getSource: async (source, opts) => {
440
+ // Reuse in-flight / resolved GeoTIFFs across MosaicLayer rebuilds
441
+ // (version bumps) and pixel-click handlers; otherwise every layer
442
+ // rebuild would re-fetch every source's header.
443
+ const cached = geotiffCache.get(source.id);
444
+ if (cached) return cached;
445
+ const promise = (async () => {
446
+ const url = await presignHref(source.href);
447
+ const geotiff = await GeoTIFF.fromUrl(url);
448
+ normalizeCogGeotiff(geotiff);
449
+ return geotiff;
450
+ })();
451
+ geotiffCache.set(source.id, promise);
452
+ const geotiff = await promise;
453
+ if (opts.signal?.aborted) throw new DOMException('Aborted', 'AbortError');
454
+ // Seed band config from the first COG that resolves so the UI and
455
+ // the pipeline match the actual raster (e.g. 4-band NAIP RGB+NIR),
456
+ // rather than the hardcoded 3-band default. Subsequent sources are
457
+ // assumed to share structure within a mosaic.
458
+ if (!probedBandCount) {
459
+ probedBandCount = true;
460
+ const count = geotiff.count ?? 3;
461
+ const sf = geotiff.cachedTags.sampleFormat?.[0] ?? 1;
462
+ const bps = geotiff.cachedTags.bitsPerSample?.[0] ?? 8;
463
+ detectedBandCount = count;
464
+ detectedDataType = buildDataTypeLabel(sf, bps);
465
+ const nextConfig = defaultBandConfig(count, sf);
466
+ bandConfig = nextConfig;
467
+ if (mapRef) scheduleLayerRebuild(mapRef, signal);
468
+ }
469
+ return geotiff;
470
+ },
471
+ renderSource: (source, { data }) => {
472
+ if (!data) return null;
473
+ const customProps = selectCogPipeline(data, {
474
+ bandConfig: bc,
475
+ rescale: rs,
476
+ onHistogram: (bins) => {
477
+ histogram = new Uint32Array(bins);
478
+ }
479
+ });
480
+ return new COGLayer({
481
+ id: `mosaic-${tab.id}-v${version}-${source.id}`,
482
+ geotiff: data,
483
+ pool: pool ?? undefined,
484
+ epsgResolver,
485
+ signal,
486
+ ...customProps
487
+ });
488
+ }
489
+ });
490
+
491
+ if (overlayRef) {
492
+ overlayRef.setProps({ layers: [layer] });
493
+ return;
494
+ }
495
+
496
+ const overlay = new MapboxOverlay({
497
+ interleaved: false,
498
+ layers: [layer],
499
+ onError: (err: Error) => {
500
+ if (signal.aborted) return;
501
+ if (!error) {
502
+ error = err?.message || String(err);
503
+ loading = false;
504
+ }
505
+ }
506
+ });
507
+ overlayRef = overlay;
508
+ map.addControl(overlay as unknown as maplibregl.IControl);
509
+ loading = false;
510
+ }
511
+
512
+ function handleConfigChange(next: BandConfig): void {
513
+ bandConfig = next;
514
+ if (!mapRef) return;
515
+ scheduleLayerRebuild(mapRef, abortController.signal);
516
+ }
517
+
518
+ function handleRescaleChange(next: RescaleConfig): void {
519
+ rescale = next;
520
+ if (!mapRef) return;
521
+ scheduleLayerRebuild(mapRef, abortController.signal);
522
+ }
523
+
524
+ function cleanup(): void {
525
+ abortController.abort();
526
+ if (rebuildTimer != null) {
527
+ clearTimeout(rebuildTimer);
528
+ rebuildTimer = null;
529
+ }
530
+ if (mapRef) removeClickHandler();
531
+ if (mapRef && overlayRef) {
532
+ try {
533
+ mapRef.removeControl(overlayRef as unknown as maplibregl.IControl);
534
+ } catch {
535
+ /* already removed */
536
+ }
537
+ }
538
+ if (mapRef) cleanupNativeBitmap(mapRef);
539
+ mapRef = null;
540
+ overlayRef = null;
541
+ itemsRef = [];
542
+ presignCache.clear();
543
+ geotiffCache.clear();
544
+ const maybeDestroy = pool as unknown as { destroy?: () => void; terminate?: () => void } | null;
545
+ if (maybeDestroy?.destroy) {
546
+ try {
547
+ maybeDestroy.destroy();
548
+ } catch {
549
+ /* ignore */
550
+ }
551
+ } else if (maybeDestroy?.terminate) {
552
+ try {
553
+ maybeDestroy.terminate();
554
+ } catch {
555
+ /* ignore */
556
+ }
557
+ }
558
+ pool = null;
559
+ }
560
+
561
+ $effect(() => {
562
+ const id = tab.id;
563
+ const unregister = tabResources.register(id, cleanup);
564
+ return unregister;
565
+ });
566
+ onDestroy(cleanup);
567
+ </script>
568
+
569
+ <div class="relative flex h-full overflow-hidden">
570
+ <div class="flex-1">
571
+ <MapContainer {onMapReady} {bounds} />
572
+ </div>
573
+
574
+ <div class="pointer-events-none absolute left-2 top-2 z-10 flex flex-col gap-1">
575
+ {#if loading}
576
+ <div class="rounded bg-card/80 px-2 py-1 text-xs text-card-foreground backdrop-blur-sm">
577
+ {t('map.loadingCog')}
578
+ </div>
579
+ {/if}
580
+ {#if sourceCount > 0}
581
+ <div class="rounded bg-card/80 px-2 py-1 text-xs text-card-foreground backdrop-blur-sm">
582
+ {sourceCount === 1
583
+ ? t('stac.mosaicSourcesOne', { count: sourceCount })
584
+ : t('stac.mosaicSourcesOther', { count: sourceCount })}
585
+ </div>
586
+ {/if}
587
+ {#if error}
588
+ <div class="pointer-events-auto max-w-sm rounded bg-red-900/80 px-2 py-1 text-xs text-red-200">
589
+ {error}
590
+ </div>
591
+ {/if}
592
+ </div>
593
+
594
+ {#if sourceCount > 0 && bandConfig}
595
+ <div class="absolute right-2 top-2 z-10 flex gap-1">
596
+ <button
597
+ class="rounded bg-card/80 px-2 py-1 text-xs text-card-foreground backdrop-blur-sm hover:bg-card"
598
+ class:ring-1={showControls}
599
+ class:ring-primary={showControls}
600
+ onclick={() => {
601
+ showControls = !showControls;
602
+ if (showControls) showInfo = false;
603
+ }}
604
+ >
605
+ {t('cog.style')}
606
+ </button>
607
+ <button
608
+ class="rounded bg-card/80 px-2 py-1 text-xs text-card-foreground backdrop-blur-sm hover:bg-card"
609
+ class:ring-1={showInfo}
610
+ class:ring-primary={showInfo}
611
+ onclick={() => {
612
+ showInfo = !showInfo;
613
+ if (showInfo) showControls = false;
614
+ }}
615
+ >
616
+ {t('map.info')}
617
+ </button>
618
+ </div>
619
+
620
+ {#if showControls}
621
+ <CogControls
622
+ bandCount={detectedBandCount}
623
+ {bandConfig}
624
+ onConfigChange={handleConfigChange}
625
+ {rescale}
626
+ rescaleApplicable={true}
627
+ onRescaleChange={handleRescaleChange}
628
+ {histogram}
629
+ />
630
+ {/if}
631
+
632
+ {#if showInfo}
633
+ <div
634
+ class="absolute right-2 top-10 z-10 max-h-[70vh] w-64 overflow-auto rounded bg-card/90 p-3 text-xs text-card-foreground backdrop-blur-sm"
635
+ >
636
+ <h3 class="mb-2 font-medium">{t('stac.mosaicInfo')}</h3>
637
+ <dl class="space-y-1.5">
638
+ <dt class="text-muted-foreground">{t('stac.mosaicSourcesLabel')}</dt>
639
+ <dd class="tabular-nums">{sourceCount}</dd>
640
+ <dt class="text-muted-foreground">{t('mapInfo.bands')}</dt>
641
+ <dd>
642
+ {detectedBandCount}{detectedDataType ? ` (${detectedDataType})` : ''}
643
+ </dd>
644
+ {#if bounds}
645
+ <dt class="text-muted-foreground">{t('mapInfo.bounds')}</dt>
646
+ <dd>
647
+ W {bounds[0].toFixed(4)}, S {bounds[1].toFixed(4)}<br />
648
+ E {bounds[2].toFixed(4)}, N {bounds[3].toFixed(4)}
649
+ </dd>
650
+ {/if}
651
+ </dl>
652
+ </div>
653
+ {/if}
654
+ {/if}
655
+
656
+ {#if pixelValue}
657
+ <div
658
+ class="absolute bottom-2 left-2 z-10 rounded bg-card/90 p-2.5 text-xs text-card-foreground backdrop-blur-sm"
659
+ >
660
+ <div class="mb-1 flex items-center justify-between gap-3">
661
+ <span class="font-medium">{t('cog.pixelValue')}</span>
662
+ <button
663
+ class="text-muted-foreground hover:text-card-foreground"
664
+ onclick={() => {
665
+ pixelValue = null;
666
+ pixelSourceId = null;
667
+ }}
668
+ >
669
+ &times;
670
+ </button>
671
+ </div>
672
+ <div class="space-y-0.5 text-muted-foreground">
673
+ <div>{pixelValue.lat.toFixed(6)}&deg;, {pixelValue.lng.toFixed(6)}&deg;</div>
674
+ <div class="text-[10px]">px ({pixelValue.col}, {pixelValue.row})</div>
675
+ {#if pixelSourceId}
676
+ <div class="truncate text-[10px]" title={pixelSourceId}>{pixelSourceId}</div>
677
+ {/if}
678
+ </div>
679
+ <div class="mt-1.5 space-y-0.5">
680
+ {#each pixelValue.values as val, i}
681
+ <div class="flex justify-between gap-2">
682
+ <span class="text-muted-foreground">{t('cog.band')} {i + 1}</span>
683
+ <span class="font-mono tabular-nums">
684
+ {Number.isInteger(val) ? val : val.toFixed(4)}
685
+ </span>
686
+ </div>
687
+ {/each}
688
+ </div>
689
+ </div>
690
+ {/if}
691
+
692
+ {#if inspecting}
693
+ <div
694
+ class="pointer-events-none absolute bottom-2 left-2 z-10 rounded bg-card/80 px-2 py-1 text-xs text-card-foreground backdrop-blur-sm"
695
+ >
696
+ {t('cog.reading')}
697
+ </div>
698
+ {/if}
699
+ </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 StacMosaicViewer: import("svelte").Component<$$ComponentProps, {}, "">;
8
+ type StacMosaicViewer = ReturnType<typeof StacMosaicViewer>;
9
+ export default StacMosaicViewer;