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