@walkthru-earth/objex 1.3.0 → 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.
- package/dist/components/viewers/CogViewer.svelte +62 -12
- package/dist/components/viewers/StacMapViewer.svelte +9 -1
- package/dist/components/viewers/StacMosaicViewer.svelte +105 -19
- package/dist/utils/cog-pure.d.ts +25 -0
- package/dist/utils/cog-pure.js +35 -0
- package/dist/utils/cog.d.ts +26 -43
- package/dist/utils/cog.js +22 -44
- package/package.json +1 -1
|
@@ -11,12 +11,14 @@ import {
|
|
|
11
11
|
type BandConfig,
|
|
12
12
|
buildDataTypeLabel,
|
|
13
13
|
type CogInfo,
|
|
14
|
+
type CustomTileData,
|
|
14
15
|
clampBounds,
|
|
15
16
|
cleanupNativeBitmap,
|
|
16
17
|
createEpsgResolver,
|
|
17
18
|
DEFAULT_RESCALE,
|
|
18
19
|
defaultBandConfig,
|
|
19
20
|
fitCogBounds,
|
|
21
|
+
HISTOGRAM_BIN_COUNT,
|
|
20
22
|
inspectCogTags,
|
|
21
23
|
needsCustomPipelineForConfig,
|
|
22
24
|
normalizeCogGeotiff,
|
|
@@ -273,16 +275,7 @@ function buildAndAddLayer(
|
|
|
273
275
|
// Pick the library-default or one of three custom pipelines. Empty when the
|
|
274
276
|
// library-default uint path runs unchanged.
|
|
275
277
|
const customProps = preflightGeotiff
|
|
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
|
-
})
|
|
278
|
+
? selectCogPipeline(preflightGeotiff, { bandConfig, rescale })
|
|
286
279
|
: {};
|
|
287
280
|
|
|
288
281
|
// Apply upstream-bug workarounds in place (overview filter, 4326 bbox clamp).
|
|
@@ -290,7 +283,10 @@ function buildAndAddLayer(
|
|
|
290
283
|
|
|
291
284
|
const cogInput = preflightGeotiff ?? resolvedHttpsUrl ?? '';
|
|
292
285
|
|
|
293
|
-
|
|
286
|
+
// Cast: `onViewportLoad` is forwarded by our pnpm patch to the inner
|
|
287
|
+
// TileLayer, but COGLayer's generated .d.ts does not expose it.
|
|
288
|
+
// biome-ignore lint/suspicious/noExplicitAny: upstream prop not yet in types
|
|
289
|
+
const cogProps: any = {
|
|
294
290
|
// Stable id per tab so rebuilds on band/style change don't force deck.gl
|
|
295
291
|
// to treat this as a brand-new layer and drop cached tile state.
|
|
296
292
|
id: `cog-layer-${tab.id}`,
|
|
@@ -299,6 +295,15 @@ function buildAndAddLayer(
|
|
|
299
295
|
epsgResolver,
|
|
300
296
|
signal,
|
|
301
297
|
...customProps,
|
|
298
|
+
// COG-native histogram: sum `content.histogram` over tiles currently
|
|
299
|
+
// visible in the viewport. Fires after every pan/zoom settles and
|
|
300
|
+
// reuses deck.gl's tile cache for free, cached tiles still carry
|
|
301
|
+
// their per-tile histogram so no rebake is needed on revisit.
|
|
302
|
+
onViewportLoad: (visibleTiles: unknown) => {
|
|
303
|
+
aggregateVisibleHistogram(
|
|
304
|
+
visibleTiles as ReadonlyArray<{ content?: unknown } | null | undefined>
|
|
305
|
+
);
|
|
306
|
+
},
|
|
302
307
|
onGeoTIFFLoad: (
|
|
303
308
|
loadedTiff: GeoTIFF,
|
|
304
309
|
{
|
|
@@ -345,7 +350,8 @@ function buildAndAddLayer(
|
|
|
345
350
|
}
|
|
346
351
|
loading = false;
|
|
347
352
|
}
|
|
348
|
-
}
|
|
353
|
+
};
|
|
354
|
+
const layer = new COGLayer(cogProps);
|
|
349
355
|
|
|
350
356
|
const overlay = new MapboxOverlay({
|
|
351
357
|
interleaved: false,
|
|
@@ -362,10 +368,54 @@ function buildAndAddLayer(
|
|
|
362
368
|
map.addControl(overlay as unknown as maplibregl.IControl);
|
|
363
369
|
}
|
|
364
370
|
|
|
371
|
+
// ─── Viewport-scoped histogram aggregation ───────────────────────
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Sum per-tile histograms from tiles currently visible in the viewport. COG
|
|
375
|
+
* pyramid semantics map cleanly: zoomed out → a handful of low-res overview
|
|
376
|
+
* tiles cover the whole scene; zoomed in → only the tiles intersecting the
|
|
377
|
+
* AOI are decoded. deck.gl reuses its tile cache on revisits so each cached
|
|
378
|
+
* tile still carries `content.histogram`, no rebake needed.
|
|
379
|
+
*/
|
|
380
|
+
function aggregateVisibleHistogram(
|
|
381
|
+
visibleTiles: ReadonlyArray<{ content?: unknown } | null | undefined>
|
|
382
|
+
): void {
|
|
383
|
+
if (!visibleTiles || visibleTiles.length === 0) {
|
|
384
|
+
histogram = null;
|
|
385
|
+
histogramTick++;
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
const summed = new Uint32Array(HISTOGRAM_BIN_COUNT);
|
|
389
|
+
let found = false;
|
|
390
|
+
for (const tile of visibleTiles) {
|
|
391
|
+
// COGLayer wraps our baker's return as `{data, forwardTransform,
|
|
392
|
+
// inverseTransform}` in `_getTileData`, so the histogram lives at
|
|
393
|
+
// `content.data.histogram`. MosaicLayer's sub-COGs follow the same
|
|
394
|
+
// shape. Fall back to `content.histogram` for future-proofing if
|
|
395
|
+
// upstream ever stops wrapping.
|
|
396
|
+
const content = tile?.content as
|
|
397
|
+
| { data?: CustomTileData; histogram?: Uint32Array }
|
|
398
|
+
| null
|
|
399
|
+
| undefined;
|
|
400
|
+
const bins = content?.data?.histogram ?? content?.histogram;
|
|
401
|
+
if (!bins || bins.length !== HISTOGRAM_BIN_COUNT) continue;
|
|
402
|
+
for (let i = 0; i < HISTOGRAM_BIN_COUNT; i++) summed[i] += bins[i];
|
|
403
|
+
found = true;
|
|
404
|
+
}
|
|
405
|
+
histogram = found ? summed : null;
|
|
406
|
+
histogramTick++;
|
|
407
|
+
}
|
|
408
|
+
|
|
365
409
|
// ─── Rebuild layer on band config change ─────────────────────────
|
|
366
410
|
|
|
367
411
|
function handleConfigChange(newConfig: BandConfig) {
|
|
368
412
|
bandConfig = newConfig;
|
|
413
|
+
// Only the single-band CPU baker emits `onHistogram`. Clear the buffer on
|
|
414
|
+
// every mode/band change so (a) switching back to RGB hides stale bars
|
|
415
|
+
// that the rescale slider would otherwise draw on top of, and (b) picking
|
|
416
|
+
// a different single band starts a fresh distribution.
|
|
417
|
+
histogram = null;
|
|
418
|
+
histogramTick = 0;
|
|
369
419
|
if (!mapRef || !geotiffRef || !isTiledRef) return;
|
|
370
420
|
|
|
371
421
|
// Remove old overlay
|
|
@@ -22,7 +22,15 @@ $effect(() => {
|
|
|
22
22
|
const iframeSrc = $derived.by(() => {
|
|
23
23
|
if (!fileUrl) return '';
|
|
24
24
|
if (variant === 'stac-browser') {
|
|
25
|
-
|
|
25
|
+
// Radiant Earth STAC Browser is a Vue Router SPA. Its
|
|
26
|
+
// `#/external/<url>` route takes the catalog URL verbatim, splitting on
|
|
27
|
+
// `/`. Feeding it an `encodeURIComponent`-encoded URL makes the router
|
|
28
|
+
// hand `https%3A%2F%2F…` to `new URL()`, which reads the collapsed
|
|
29
|
+
// authority as a malformed port and throws
|
|
30
|
+
// `Port "%2F%2Fstorage.googleapis.com%2F…" is not a valid port`.
|
|
31
|
+
// Only escape the `#` character (which would otherwise terminate the
|
|
32
|
+
// hash route) so the rest of the URL flows through intact.
|
|
33
|
+
return `https://radiantearth.github.io/stac-browser/#/external/${fileUrl.replace(/#/g, '%23')}`;
|
|
26
34
|
}
|
|
27
35
|
return `https://developmentseed.org/stac-map?href=${encodeURIComponent(fileUrl)}`;
|
|
28
36
|
});
|
|
@@ -11,15 +11,18 @@ import { buildProviderBaseUrl, type ProviderId } from '../../storage/providers.j
|
|
|
11
11
|
import { connectionStore } from '../../stores/connections.svelte.js';
|
|
12
12
|
import { tabResources } from '../../stores/tab-resources.svelte.js';
|
|
13
13
|
import type { Tab } from '../../types.js';
|
|
14
|
+
import { resolveCloudUrl } from '../../utils/cloud-url.js';
|
|
14
15
|
import {
|
|
15
16
|
type BandConfig,
|
|
16
17
|
buildDataTypeLabel,
|
|
18
|
+
type CustomTileData,
|
|
17
19
|
clampBounds,
|
|
18
20
|
cleanupNativeBitmap,
|
|
19
21
|
createEpsgResolver,
|
|
20
22
|
DEFAULT_RESCALE,
|
|
21
23
|
defaultBandConfig,
|
|
22
24
|
fitCogBounds,
|
|
25
|
+
HISTOGRAM_BIN_COUNT,
|
|
23
26
|
normalizeCogGeotiff,
|
|
24
27
|
type PixelValue,
|
|
25
28
|
type RescaleConfig,
|
|
@@ -72,6 +75,11 @@ let lastRebuildAt = 0;
|
|
|
72
75
|
let layerVersion = 0;
|
|
73
76
|
let presignCache = new Map<string, Promise<string>>();
|
|
74
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>();
|
|
75
83
|
|
|
76
84
|
// MosaicLayer builds a Flatbush spatial index at construction; deck.gl reuses
|
|
77
85
|
// the existing internal tileset when only props change, so the index never
|
|
@@ -114,6 +122,7 @@ function resetViewer(): void {
|
|
|
114
122
|
itemsRef = [];
|
|
115
123
|
presignCache = new Map();
|
|
116
124
|
geotiffCache = new Map();
|
|
125
|
+
sourceHistograms = new Map();
|
|
117
126
|
loading = true;
|
|
118
127
|
error = null;
|
|
119
128
|
sourceCount = 0;
|
|
@@ -222,19 +231,24 @@ function extractConnectionKey(href: string): string | null {
|
|
|
222
231
|
function presignHref(href: string): Promise<string> {
|
|
223
232
|
let cached = presignCache.get(href);
|
|
224
233
|
if (!cached) {
|
|
225
|
-
|
|
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)) {
|
|
226
240
|
// Absolute URLs that belong to the tab's own bucket still need SigV4
|
|
227
|
-
// presigning on private buckets (GCS/S3)
|
|
241
|
+
// presigning on private buckets (GCS/S3), `new URL(rel, base)` strips
|
|
228
242
|
// the base's query string when absolutizing asset hrefs, so the
|
|
229
243
|
// signature is lost and the bare URL 403s.
|
|
230
|
-
const key = extractConnectionKey(
|
|
244
|
+
const key = extractConnectionKey(normalized);
|
|
231
245
|
if (key !== null) {
|
|
232
|
-
cached = buildHttpsUrlAsync({ ...tab, path: key } as Tab).catch(() =>
|
|
246
|
+
cached = buildHttpsUrlAsync({ ...tab, path: key } as Tab).catch(() => normalized);
|
|
233
247
|
} else {
|
|
234
|
-
cached = Promise.resolve(
|
|
248
|
+
cached = Promise.resolve(normalized);
|
|
235
249
|
}
|
|
236
250
|
} else {
|
|
237
|
-
cached = buildHttpsUrlAsync({ ...tab, path:
|
|
251
|
+
cached = buildHttpsUrlAsync({ ...tab, path: normalized } as Tab).catch(() => normalized);
|
|
238
252
|
}
|
|
239
253
|
presignCache.set(href, cached);
|
|
240
254
|
}
|
|
@@ -441,7 +455,7 @@ function buildOrUpdateLayer(map: maplibregl.Map, signal: AbortSignal): void {
|
|
|
441
455
|
// (version bumps) and pixel-click handlers; otherwise every layer
|
|
442
456
|
// rebuild would re-fetch every source's header.
|
|
443
457
|
const cached = geotiffCache.get(source.id);
|
|
444
|
-
if (cached) return cached;
|
|
458
|
+
if (cached) return cached.catch(() => undefined as unknown as GeoTIFF);
|
|
445
459
|
const promise = (async () => {
|
|
446
460
|
const url = await presignHref(source.href);
|
|
447
461
|
const geotiff = await GeoTIFF.fromUrl(url);
|
|
@@ -449,7 +463,17 @@ function buildOrUpdateLayer(map: maplibregl.Map, signal: AbortSignal): void {
|
|
|
449
463
|
return geotiff;
|
|
450
464
|
})();
|
|
451
465
|
geotiffCache.set(source.id, promise);
|
|
452
|
-
|
|
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
|
+
}
|
|
453
477
|
if (opts.signal?.aborted) throw new DOMException('Aborted', 'AbortError');
|
|
454
478
|
// Seed band config from the first COG that resolves so the UI and
|
|
455
479
|
// the pipeline match the actual raster (e.g. 4-band NAIP RGB+NIR),
|
|
@@ -470,21 +494,30 @@ function buildOrUpdateLayer(map: maplibregl.Map, signal: AbortSignal): void {
|
|
|
470
494
|
},
|
|
471
495
|
renderSource: (source, { data }) => {
|
|
472
496
|
if (!data) return null;
|
|
473
|
-
const customProps = selectCogPipeline(data, {
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
return new COGLayer({
|
|
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 = {
|
|
481
504
|
id: `mosaic-${tab.id}-v${version}-${source.id}`,
|
|
482
505
|
geotiff: data,
|
|
483
506
|
pool: pool ?? undefined,
|
|
484
507
|
epsgResolver,
|
|
485
508
|
signal,
|
|
486
|
-
...customProps
|
|
487
|
-
|
|
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);
|
|
488
521
|
}
|
|
489
522
|
});
|
|
490
523
|
|
|
@@ -511,10 +544,63 @@ function buildOrUpdateLayer(map: maplibregl.Map, signal: AbortSignal): void {
|
|
|
511
544
|
|
|
512
545
|
function handleConfigChange(next: BandConfig): void {
|
|
513
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();
|
|
514
553
|
if (!mapRef) return;
|
|
515
554
|
scheduleLayerRebuild(mapRef, abortController.signal);
|
|
516
555
|
}
|
|
517
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
|
+
|
|
518
604
|
function handleRescaleChange(next: RescaleConfig): void {
|
|
519
605
|
rescale = next;
|
|
520
606
|
if (!mapRef) return;
|
|
@@ -623,7 +709,7 @@ onDestroy(cleanup);
|
|
|
623
709
|
{bandConfig}
|
|
624
710
|
onConfigChange={handleConfigChange}
|
|
625
711
|
{rescale}
|
|
626
|
-
rescaleApplicable={
|
|
712
|
+
rescaleApplicable={bandConfig?.mode === 'single'}
|
|
627
713
|
onRescaleChange={handleRescaleChange}
|
|
628
714
|
{histogram}
|
|
629
715
|
/>
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** SampleFormat tag value → human label. */
|
|
2
|
+
export declare const SF_LABELS: Record<number, string>;
|
|
3
|
+
export interface GeoBounds {
|
|
4
|
+
west: number;
|
|
5
|
+
south: number;
|
|
6
|
+
east: number;
|
|
7
|
+
north: number;
|
|
8
|
+
}
|
|
9
|
+
export interface CogInfo {
|
|
10
|
+
width: number;
|
|
11
|
+
height: number;
|
|
12
|
+
bandCount: number;
|
|
13
|
+
dataType: string;
|
|
14
|
+
bounds: GeoBounds;
|
|
15
|
+
downsampled?: boolean;
|
|
16
|
+
}
|
|
17
|
+
/** Safely clamp a number to a range, treating NaN/Infinity as the fallback. */
|
|
18
|
+
export declare function safeClamp(v: number, lo: number, hi: number, fallback: number): number;
|
|
19
|
+
/** Clamp geographic bounds to valid MapLibre web-Mercator range. */
|
|
20
|
+
export declare function clampBounds(b: GeoBounds): GeoBounds;
|
|
21
|
+
/**
|
|
22
|
+
* Build a data-type label from GeoTIFF sample format and bits per sample.
|
|
23
|
+
* e.g. "uint8", "float32", "int16"
|
|
24
|
+
*/
|
|
25
|
+
export declare function buildDataTypeLabel(sampleFormat: number, bitsPerSample: number): string;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Dependency-free subset of `cog.ts` so that `@walkthru-earth/objex-utils`
|
|
2
|
+
// can re-export these helpers without dragging in `@developmentseed/epsg`,
|
|
3
|
+
// `@developmentseed/geotiff`, `@developmentseed/proj`, `proj4`, or
|
|
4
|
+
// `maplibre-gl`. tsup preserves bare side-effect imports from externalized
|
|
5
|
+
// modules even when all named bindings are tree-shaken, so the pure surface
|
|
6
|
+
// MUST live in a module that has zero heavy imports.
|
|
7
|
+
/** SampleFormat tag value → human label. */
|
|
8
|
+
export const SF_LABELS = {
|
|
9
|
+
1: 'uint',
|
|
10
|
+
2: 'int',
|
|
11
|
+
3: 'float',
|
|
12
|
+
4: 'void',
|
|
13
|
+
5: 'complex int',
|
|
14
|
+
6: 'complex float'
|
|
15
|
+
};
|
|
16
|
+
/** Safely clamp a number to a range, treating NaN/Infinity as the fallback. */
|
|
17
|
+
export function safeClamp(v, lo, hi, fallback) {
|
|
18
|
+
return Number.isFinite(v) ? Math.max(lo, Math.min(hi, v)) : fallback;
|
|
19
|
+
}
|
|
20
|
+
/** Clamp geographic bounds to valid MapLibre web-Mercator range. */
|
|
21
|
+
export function clampBounds(b) {
|
|
22
|
+
return {
|
|
23
|
+
west: safeClamp(b.west, -180, 180, -180),
|
|
24
|
+
south: safeClamp(b.south, -85.051129, 85.051129, -85.051129),
|
|
25
|
+
east: safeClamp(b.east, -180, 180, 180),
|
|
26
|
+
north: safeClamp(b.north, -85.051129, 85.051129, 85.051129)
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Build a data-type label from GeoTIFF sample format and bits per sample.
|
|
31
|
+
* e.g. "uint8", "float32", "int16"
|
|
32
|
+
*/
|
|
33
|
+
export function buildDataTypeLabel(sampleFormat, bitsPerSample) {
|
|
34
|
+
return `${SF_LABELS[sampleFormat] ?? `sf${sampleFormat}`}${bitsPerSample ?? ''}`;
|
|
35
|
+
}
|
package/dist/utils/cog.d.ts
CHANGED
|
@@ -5,9 +5,9 @@ import { GeoTIFF } from '@developmentseed/geotiff';
|
|
|
5
5
|
import type { EpsgResolver } from '@developmentseed/proj';
|
|
6
6
|
import type { Device } from '@luma.gl/core';
|
|
7
7
|
import type maplibregl from 'maplibre-gl';
|
|
8
|
+
import { buildDataTypeLabel, type CogInfo, clampBounds, type GeoBounds, SF_LABELS, safeClamp } from './cog-pure.js';
|
|
8
9
|
import { type ColormapName } from './colormap-sprite.js';
|
|
9
|
-
|
|
10
|
-
export declare const SF_LABELS: Record<number, string>;
|
|
10
|
+
export { buildDataTypeLabel, type CogInfo, clampBounds, type GeoBounds, SF_LABELS, safeClamp };
|
|
11
11
|
/**
|
|
12
12
|
* Any of the 107 named ramps shipped in `@developmentseed/deck.gl-raster`'s
|
|
13
13
|
* `colormaps.png` sprite (matplotlib + rio-tiler + cmocean). Rendering is
|
|
@@ -120,11 +120,6 @@ export interface SelectCogPipelineOptions {
|
|
|
120
120
|
bandConfig?: BandConfig | null;
|
|
121
121
|
/** Linear rescale GPU module values. No-op when omitted or at defaults. */
|
|
122
122
|
rescale?: RescaleConfig;
|
|
123
|
-
/**
|
|
124
|
-
* Forwarded to the CPU bake factories in single-band mode; receives a
|
|
125
|
-
* 64-bin histogram of normalized data after each tile for slider UI.
|
|
126
|
-
*/
|
|
127
|
-
onHistogram?: (bins: Uint32Array) => void;
|
|
128
123
|
}
|
|
129
124
|
/**
|
|
130
125
|
* Decide which getTileData/renderTile pair COGLayer should use for a GeoTIFF.
|
|
@@ -143,29 +138,6 @@ export interface SelectCogPipelineOptions {
|
|
|
143
138
|
* viewers can call it per sub-COG without re-implementing the decision tree.
|
|
144
139
|
*/
|
|
145
140
|
export declare function selectCogPipeline(geotiff: GeoTIFFType, opts?: SelectCogPipelineOptions): ResolvedCogPipeline;
|
|
146
|
-
export interface GeoBounds {
|
|
147
|
-
west: number;
|
|
148
|
-
south: number;
|
|
149
|
-
east: number;
|
|
150
|
-
north: number;
|
|
151
|
-
}
|
|
152
|
-
export interface CogInfo {
|
|
153
|
-
width: number;
|
|
154
|
-
height: number;
|
|
155
|
-
bandCount: number;
|
|
156
|
-
dataType: string;
|
|
157
|
-
bounds: GeoBounds;
|
|
158
|
-
downsampled?: boolean;
|
|
159
|
-
}
|
|
160
|
-
/** Safely clamp a number to a range, treating NaN/Infinity as the fallback. */
|
|
161
|
-
export declare function safeClamp(v: number, lo: number, hi: number, fallback: number): number;
|
|
162
|
-
/** Clamp geographic bounds to valid MapLibre web-Mercator range. */
|
|
163
|
-
export declare function clampBounds(b: GeoBounds): GeoBounds;
|
|
164
|
-
/**
|
|
165
|
-
* Build a data-type label from GeoTIFF sample format and bits per sample.
|
|
166
|
-
* e.g. "uint8", "float32", "int16"
|
|
167
|
-
*/
|
|
168
|
-
export declare function buildDataTypeLabel(sampleFormat: number, bitsPerSample: number): string;
|
|
169
141
|
/**
|
|
170
142
|
* Query the GPU's MAX_TEXTURE_SIZE from MapLibre's WebGL context.
|
|
171
143
|
* Falls back to 4096 (lowest common denominator for mobile GPUs).
|
|
@@ -215,6 +187,16 @@ export interface CustomTileData {
|
|
|
215
187
|
* lookup. `undefined` for RGB tiles.
|
|
216
188
|
*/
|
|
217
189
|
nodataSentinel?: number;
|
|
190
|
+
/**
|
|
191
|
+
* Per-tile 64-bin normalized histogram (0..1, nodata excluded) baked during
|
|
192
|
+
* single-band CPU decoding. `undefined` for RGB tiles. deck.gl's TileLayer
|
|
193
|
+
* caches the returned tile object, so this array is retained alongside the
|
|
194
|
+
* bitmap without a rebake on pan/zoom revisits. Summing the histograms of
|
|
195
|
+
* currently-visible tiles, via the TileLayer `onViewportLoad` hook, gives a
|
|
196
|
+
* cloud-native "histogram of what COG tiles the viewport currently shows at
|
|
197
|
+
* the active overview level", matching COG pyramid behavior.
|
|
198
|
+
*/
|
|
199
|
+
histogram?: Uint32Array;
|
|
218
200
|
}
|
|
219
201
|
type Texture = import('@luma.gl/core').Texture;
|
|
220
202
|
/**
|
|
@@ -223,16 +205,18 @@ type Texture = import('@luma.gl/core').Texture;
|
|
|
223
205
|
* Signed int (2) and float (3) need custom getTileData/renderTile.
|
|
224
206
|
*/
|
|
225
207
|
export declare function needsCustomPipeline(geotiff: GeoTIFFType): boolean;
|
|
226
|
-
/**
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
208
|
+
/**
|
|
209
|
+
* Shared options for the CPU tile-baking factories.
|
|
210
|
+
*
|
|
211
|
+
* The previous `onHistogram` callback accumulated a single closure-owned buffer
|
|
212
|
+
* across every tile ever baked, which grew unbounded on pan/zoom and never
|
|
213
|
+
* reflected "what the viewport currently shows". Histograms are now attached
|
|
214
|
+
* per tile to `CustomTileData.histogram` and aggregated by the viewer from
|
|
215
|
+
* TileLayer's `onViewportLoad(visibleTiles)` hook, matching COG overview-level
|
|
216
|
+
* behavior (few big tiles when zoomed out, small AOI-scoped tiles when zoomed
|
|
217
|
+
* in) and reusing deck.gl's tile cache for free.
|
|
218
|
+
*/
|
|
219
|
+
export type CustomGetTileDataOptions = Record<string, never>;
|
|
236
220
|
/** Number of histogram buckets produced by the CPU bake. */
|
|
237
221
|
export declare const HISTOGRAM_BIN_COUNT = 64;
|
|
238
222
|
/**
|
|
@@ -243,7 +227,7 @@ export declare const HISTOGRAM_BIN_COUNT = 64;
|
|
|
243
227
|
* `colormaps.png`. Reserves `r = 0` for nodata so `FilterNoDataVal` can
|
|
244
228
|
* discard those fragments before the ramp sample.
|
|
245
229
|
*/
|
|
246
|
-
export declare function createCustomGetTileData(geotiff: GeoTIFFType,
|
|
230
|
+
export declare function createCustomGetTileData(geotiff: GeoTIFFType, _opts?: CustomGetTileDataOptions): (image: GeoTIFFType | Overview, options: {
|
|
247
231
|
x: number;
|
|
248
232
|
y: number;
|
|
249
233
|
pool: unknown;
|
|
@@ -269,7 +253,7 @@ export declare function buildCustomRenderTile(config: BandConfig, rescale?: Resc
|
|
|
269
253
|
* single-band mode (band N normalized into the `r` channel; the ramp is
|
|
270
254
|
* applied downstream by the GPU `Colormap` module via `buildCustomRenderTile`).
|
|
271
255
|
*/
|
|
272
|
-
export declare function createConfigurableGetTileData(geotiff: GeoTIFFType, config: BandConfig,
|
|
256
|
+
export declare function createConfigurableGetTileData(geotiff: GeoTIFFType, config: BandConfig, _opts?: CustomGetTileDataOptions): (image: GeoTIFFType | Overview, options: {
|
|
273
257
|
x: number;
|
|
274
258
|
y: number;
|
|
275
259
|
pool: unknown;
|
|
@@ -303,4 +287,3 @@ export declare function resolveProj4Def(crs: number | unknown, _signal: AbortSig
|
|
|
303
287
|
* Converts WGS84 → source CRS → pixel coords, fetches the tile, reads all bands.
|
|
304
288
|
*/
|
|
305
289
|
export declare function readPixelAtLngLat(geotiff: GeoTIFFType, lng: number, lat: number, proj4Def: string | null, pool: any, signal?: AbortSignal): Promise<PixelValue | null>;
|
|
306
|
-
export {};
|
package/dist/utils/cog.js
CHANGED
|
@@ -5,7 +5,9 @@ import epsgCsvUrl from '@developmentseed/epsg/all.csv.gz?url';
|
|
|
5
5
|
import { GeoTIFF } from '@developmentseed/geotiff';
|
|
6
6
|
import { parseWkt } from '@developmentseed/proj';
|
|
7
7
|
import proj4Lib from 'proj4';
|
|
8
|
+
import { buildDataTypeLabel, clampBounds, SF_LABELS, safeClamp } from './cog-pure.js';
|
|
8
9
|
import { COLORMAP_INDEX, getColormapTexture } from './colormap-sprite.js';
|
|
10
|
+
export { buildDataTypeLabel, clampBounds, SF_LABELS, safeClamp };
|
|
9
11
|
// ─── Constants ───────────────────────────────────────────────────
|
|
10
12
|
/**
|
|
11
13
|
* Patches a GLSL ES 3.00 compile error in `@developmentseed/deck.gl-raster`
|
|
@@ -27,15 +29,6 @@ const Sampler2DArrayPrecision = {
|
|
|
27
29
|
'fs:#decl': 'precision highp sampler2DArray;\n'
|
|
28
30
|
}
|
|
29
31
|
};
|
|
30
|
-
/** SampleFormat tag value → human label. */
|
|
31
|
-
export const SF_LABELS = {
|
|
32
|
-
1: 'uint',
|
|
33
|
-
2: 'int',
|
|
34
|
-
3: 'float',
|
|
35
|
-
4: 'void',
|
|
36
|
-
5: 'complex int',
|
|
37
|
-
6: 'complex float'
|
|
38
|
-
};
|
|
39
32
|
/** Create a sensible default band config based on COG metadata. */
|
|
40
33
|
export function defaultBandConfig(bandCount, sampleFormat) {
|
|
41
34
|
if (bandCount >= 3 && bandCount <= 4) {
|
|
@@ -248,9 +241,7 @@ export function selectCogPipeline(geotiff, opts = {}) {
|
|
|
248
241
|
: needsCustomPipeline(geotiff);
|
|
249
242
|
if (useCustom && bandConfig) {
|
|
250
243
|
return {
|
|
251
|
-
getTileData: createConfigurableGetTileData(geotiff, bandConfig,
|
|
252
|
-
onHistogram: opts.onHistogram
|
|
253
|
-
}),
|
|
244
|
+
getTileData: createConfigurableGetTileData(geotiff, bandConfig),
|
|
254
245
|
renderTile: buildCustomRenderTile(bandConfig, rescale)
|
|
255
246
|
};
|
|
256
247
|
}
|
|
@@ -260,7 +251,7 @@ export function selectCogPipeline(geotiff, opts = {}) {
|
|
|
260
251
|
const fallbackSf = geotiff.cachedTags.sampleFormat?.[0] ?? 1;
|
|
261
252
|
const fallbackConfig = defaultBandConfig(geotiff.count, fallbackSf);
|
|
262
253
|
return {
|
|
263
|
-
getTileData: createCustomGetTileData(geotiff
|
|
254
|
+
getTileData: createCustomGetTileData(geotiff),
|
|
264
255
|
renderTile: buildCustomRenderTile(fallbackConfig, rescale)
|
|
265
256
|
};
|
|
266
257
|
}
|
|
@@ -275,27 +266,9 @@ export function selectCogPipeline(geotiff, opts = {}) {
|
|
|
275
266
|
}
|
|
276
267
|
const BITMAP_SOURCE = 'geotiff-bitmap-src';
|
|
277
268
|
const BITMAP_LAYER = 'geotiff-bitmap-layer';
|
|
278
|
-
// ───
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
return Number.isFinite(v) ? Math.max(lo, Math.min(hi, v)) : fallback;
|
|
282
|
-
}
|
|
283
|
-
/** Clamp geographic bounds to valid MapLibre web-Mercator range. */
|
|
284
|
-
export function clampBounds(b) {
|
|
285
|
-
return {
|
|
286
|
-
west: safeClamp(b.west, -180, 180, -180),
|
|
287
|
-
south: safeClamp(b.south, -85.051129, 85.051129, -85.051129),
|
|
288
|
-
east: safeClamp(b.east, -180, 180, 180),
|
|
289
|
-
north: safeClamp(b.north, -85.051129, 85.051129, 85.051129)
|
|
290
|
-
};
|
|
291
|
-
}
|
|
292
|
-
/**
|
|
293
|
-
* Build a data-type label from GeoTIFF sample format and bits per sample.
|
|
294
|
-
* e.g. "uint8", "float32", "int16"
|
|
295
|
-
*/
|
|
296
|
-
export function buildDataTypeLabel(sampleFormat, bitsPerSample) {
|
|
297
|
-
return `${SF_LABELS[sampleFormat] ?? `sf${sampleFormat}`}${bitsPerSample ?? ''}`;
|
|
298
|
-
}
|
|
269
|
+
// ─── Types & pure helpers ────────────────────────────────────────
|
|
270
|
+
// `GeoBounds`, `CogInfo`, `safeClamp`, `clampBounds`, `buildDataTypeLabel`
|
|
271
|
+
// live in `./cog-pure.ts` and are re-exported at the top of this file.
|
|
299
272
|
// ─── Map helpers (depend on maplibre-gl) ─────────────────────────
|
|
300
273
|
/**
|
|
301
274
|
* Query the GPU's MAX_TEXTURE_SIZE from MapLibre's WebGL context.
|
|
@@ -614,7 +587,7 @@ export const HISTOGRAM_BIN_COUNT = 64;
|
|
|
614
587
|
* `colormaps.png`. Reserves `r = 0` for nodata so `FilterNoDataVal` can
|
|
615
588
|
* discard those fragments before the ramp sample.
|
|
616
589
|
*/
|
|
617
|
-
export function createCustomGetTileData(geotiff,
|
|
590
|
+
export function createCustomGetTileData(geotiff, _opts = {}) {
|
|
618
591
|
// Read Scale/Offset TIFF tags (GDAL convention for scaled datasets like DEMs)
|
|
619
592
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
620
593
|
const tags = geotiff.cachedTags;
|
|
@@ -649,7 +622,6 @@ export function createCustomGetTileData(geotiff, opts = {}) {
|
|
|
649
622
|
let sharedMax = globalMax;
|
|
650
623
|
// Resolve the sprite texture from the first tile's device; reuse per-device.
|
|
651
624
|
let texturePromise = null;
|
|
652
|
-
const histogram = isSingleBand ? new Uint32Array(HISTOGRAM_BIN_COUNT) : null;
|
|
653
625
|
return async (image, options) => {
|
|
654
626
|
if (isSingleBand && !texturePromise) {
|
|
655
627
|
texturePromise = getColormapTexture(options.device);
|
|
@@ -669,6 +641,10 @@ export function createCustomGetTileData(geotiff, opts = {}) {
|
|
|
669
641
|
const pixelCount = width * height;
|
|
670
642
|
const scale = gdalScale ?? 1;
|
|
671
643
|
const offset = gdalOffset ?? 0;
|
|
644
|
+
// Allocate per-tile histogram so deck.gl's tile cache retains it with
|
|
645
|
+
// the tile object. The viewer sums histograms of visible tiles from
|
|
646
|
+
// TileLayer's `onViewportLoad` hook, no shared accumulator needed.
|
|
647
|
+
const histogram = isSingleBand ? new Uint32Array(HISTOGRAM_BIN_COUNT) : null;
|
|
672
648
|
// When no global stats, scan this tile and widen the shared range
|
|
673
649
|
if (sharedMin === null || sharedMax === null) {
|
|
674
650
|
let tMin = Infinity;
|
|
@@ -734,14 +710,13 @@ export function createCustomGetTileData(geotiff, opts = {}) {
|
|
|
734
710
|
}
|
|
735
711
|
rgba[idx + 3] = 255;
|
|
736
712
|
}
|
|
737
|
-
if (histogram && opts.onHistogram)
|
|
738
|
-
opts.onHistogram(histogram);
|
|
739
713
|
return {
|
|
740
714
|
imageData: new ImageData(rgba, width, height),
|
|
741
715
|
width,
|
|
742
716
|
height,
|
|
743
717
|
colormapTexture: isSingleBand ? colormapTexture : undefined,
|
|
744
|
-
nodataSentinel: isSingleBand ? 0 : undefined
|
|
718
|
+
nodataSentinel: isSingleBand ? 0 : undefined,
|
|
719
|
+
histogram: histogram ?? undefined
|
|
745
720
|
};
|
|
746
721
|
};
|
|
747
722
|
}
|
|
@@ -849,14 +824,13 @@ function computeBandRanges(bands, bandIndices, pixelCount, nodata) {
|
|
|
849
824
|
* single-band mode (band N normalized into the `r` channel; the ramp is
|
|
850
825
|
* applied downstream by the GPU `Colormap` module via `buildCustomRenderTile`).
|
|
851
826
|
*/
|
|
852
|
-
export function createConfigurableGetTileData(geotiff, config,
|
|
827
|
+
export function createConfigurableGetTileData(geotiff, config, _opts = {}) {
|
|
853
828
|
const bandCount = geotiff.count;
|
|
854
829
|
// Shared per-band ranges across tiles (seeded on first tile, widened by subsequent)
|
|
855
830
|
const sharedMins = new Map();
|
|
856
831
|
const sharedMaxs = new Map();
|
|
857
832
|
// Resolve the sprite texture from the first tile's device; reuse per-device.
|
|
858
833
|
let texturePromise = null;
|
|
859
|
-
const histogram = config.mode === 'single' ? new Uint32Array(HISTOGRAM_BIN_COUNT) : null;
|
|
860
834
|
return async (image, options) => {
|
|
861
835
|
if (config.mode === 'single' && !texturePromise) {
|
|
862
836
|
texturePromise = getColormapTexture(options.device);
|
|
@@ -875,6 +849,11 @@ export function createConfigurableGetTileData(geotiff, config, opts = {}) {
|
|
|
875
849
|
const pixelCount = width * height;
|
|
876
850
|
const bands = extractBands(arr, bandCount, pixelCount);
|
|
877
851
|
const rgba = new Uint8ClampedArray(pixelCount * 4);
|
|
852
|
+
// Per-tile histogram, cached by deck.gl's tile cache with the tile
|
|
853
|
+
// object. Cloud-native by construction: at each zoom level, COG only
|
|
854
|
+
// decodes the overview tiles that cover the viewport, so the summed
|
|
855
|
+
// histogram naturally reflects "what the user is looking at right now".
|
|
856
|
+
const histogram = config.mode === 'single' ? new Uint32Array(HISTOGRAM_BIN_COUNT) : null;
|
|
878
857
|
if (config.mode === 'rgb') {
|
|
879
858
|
// RGB mode: map 3 bands to R, G, B
|
|
880
859
|
const indices = [config.rBand, config.gBand, config.bBand];
|
|
@@ -955,15 +934,14 @@ export function createConfigurableGetTileData(geotiff, config, opts = {}) {
|
|
|
955
934
|
}
|
|
956
935
|
}
|
|
957
936
|
}
|
|
958
|
-
if (histogram && opts.onHistogram)
|
|
959
|
-
opts.onHistogram(histogram);
|
|
960
937
|
}
|
|
961
938
|
return {
|
|
962
939
|
imageData: new ImageData(rgba, width, height),
|
|
963
940
|
width,
|
|
964
941
|
height,
|
|
965
942
|
colormapTexture: config.mode === 'single' ? colormapTexture : undefined,
|
|
966
|
-
nodataSentinel: config.mode === 'single' ? 0 : undefined
|
|
943
|
+
nodataSentinel: config.mode === 'single' ? 0 : undefined,
|
|
944
|
+
histogram: histogram ?? undefined
|
|
967
945
|
};
|
|
968
946
|
};
|
|
969
947
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@walkthru-earth/objex",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.1",
|
|
4
4
|
"description": "Svelte 5 components and utilities for exploring geospatial object storage — S3, GCS, Azure, R2",
|
|
5
5
|
"author": "Youssef Harby <yharby@walkthru.earth>",
|
|
6
6
|
"license": "CC-BY-4.0",
|