@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.
@@ -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
- const layer = new COGLayer({
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
- return `https://radiantearth.github.io/stac-browser/#/external/${encodeURIComponent(fileUrl)}`;
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
- if (/^https?:\/\//i.test(href)) {
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) `new URL(rel, base)` strips
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(href);
244
+ const key = extractConnectionKey(normalized);
231
245
  if (key !== null) {
232
- cached = buildHttpsUrlAsync({ ...tab, path: key } as Tab).catch(() => href);
246
+ cached = buildHttpsUrlAsync({ ...tab, path: key } as Tab).catch(() => normalized);
233
247
  } else {
234
- cached = Promise.resolve(href);
248
+ cached = Promise.resolve(normalized);
235
249
  }
236
250
  } else {
237
- cached = buildHttpsUrlAsync({ ...tab, path: href } as Tab).catch(() => href);
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
- const geotiff = await 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
+ }
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
- bandConfig: bc,
475
- rescale: rs,
476
- onHistogram: (bins) => {
477
- histogram = new Uint32Array(bins);
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={true}
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
+ }
@@ -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
- /** SampleFormat tag value human label. */
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
- /** Shared options for the CPU tile-baking factories. */
227
- export interface CustomGetTileDataOptions {
228
- /**
229
- * Called after each baked tile with a 64-bin histogram of normalized
230
- * single-band values (0..1, nodata excluded). Bins accumulate across
231
- * tiles; receivers should treat the array as monotonically growing and
232
- * debounce UI updates. Never invoked in RGB mode.
233
- */
234
- onHistogram?: (bins: Uint32Array) => void;
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, opts?: CustomGetTileDataOptions): (image: GeoTIFFType | Overview, options: {
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, opts?: CustomGetTileDataOptions): (image: GeoTIFFType | Overview, options: {
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, { onHistogram: opts.onHistogram }),
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
- // ─── Pure helpers ────────────────────────────────────────────────
279
- /** Safely clamp a number to a range, treating NaN/Infinity as the fallback. */
280
- export function safeClamp(v, lo, hi, fallback) {
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, opts = {}) {
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, opts = {}) {
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.0",
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",