@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
package/dist/utils/cog.js CHANGED
@@ -1,103 +1,37 @@
1
1
  import { inferRenderPipeline } from '@developmentseed/deck.gl-geotiff';
2
- import { LinearRescale } from '@developmentseed/deck.gl-raster/gpu-modules';
2
+ import { Colormap, FilterNoDataVal, LinearRescale } from '@developmentseed/deck.gl-raster/gpu-modules';
3
3
  import loadEpsg from '@developmentseed/epsg/all';
4
4
  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';
9
+ import { COLORMAP_INDEX, getColormapTexture } from './colormap-sprite.js';
10
+ export { buildDataTypeLabel, clampBounds, SF_LABELS, safeClamp };
8
11
  // ─── Constants ───────────────────────────────────────────────────
9
- /** SampleFormat tag value → human label. */
10
- export const SF_LABELS = {
11
- 1: 'uint',
12
- 2: 'int',
13
- 3: 'float',
14
- 4: 'void',
15
- 5: 'complex int',
16
- 6: 'complex float'
17
- };
18
- export const COLOR_RAMP_STOPS = {
19
- grayscale: [
20
- [0, 0, 0],
21
- [255, 255, 255]
22
- ],
23
- terrain: [
24
- [0, 0, 128],
25
- [0, 100, 200],
26
- [0, 154, 80],
27
- [120, 180, 50],
28
- [200, 170, 60],
29
- [180, 120, 50],
30
- [140, 90, 40],
31
- [200, 200, 200],
32
- [255, 255, 255]
33
- ],
34
- viridis: [
35
- [68, 1, 84],
36
- [72, 36, 117],
37
- [64, 67, 135],
38
- [52, 94, 141],
39
- [33, 145, 140],
40
- [43, 176, 127],
41
- [95, 201, 97],
42
- [186, 222, 39],
43
- [253, 231, 37]
44
- ],
45
- magma: [
46
- [0, 0, 4],
47
- [22, 11, 57],
48
- [67, 15, 98],
49
- [114, 24, 114],
50
- [161, 48, 104],
51
- [206, 82, 83],
52
- [237, 132, 62],
53
- [251, 192, 75],
54
- [252, 253, 191]
55
- ],
56
- turbo: [
57
- [48, 18, 59],
58
- [31, 82, 188],
59
- [23, 158, 227],
60
- [47, 212, 161],
61
- [121, 238, 104],
62
- [193, 241, 57],
63
- [245, 206, 27],
64
- [253, 141, 31],
65
- [213, 47, 24]
66
- ],
67
- spectral: [
68
- [158, 1, 66],
69
- [213, 62, 79],
70
- [244, 109, 67],
71
- [253, 174, 97],
72
- [254, 224, 139],
73
- [255, 255, 191],
74
- [230, 245, 152],
75
- [171, 221, 164],
76
- [94, 79, 162]
77
- ]
12
+ /**
13
+ * Patches a GLSL ES 3.00 compile error in `@developmentseed/deck.gl-raster`
14
+ * v0.6.0-alpha.1. The `Colormap` shader module injects
15
+ * `uniform sampler2DArray colormapTexture;` without a precision qualifier,
16
+ * which the Apple-GPU path of luma.gl's WebGL2 backend rejects with
17
+ * `ERROR: 'sampler2DArray' : No precision specified`. In GLSL ES 3.00,
18
+ * every sampler type other than `sampler2D`/`samplerCube` needs explicit
19
+ * precision in fragment shaders.
20
+ *
21
+ * Chain this module immediately BEFORE `Colormap` in the renderPipeline so
22
+ * the combined `fs:#decl` inject emits the precision declaration first,
23
+ * then the sampler uniform. Remove once upstream ships the precision fix.
24
+ */
25
+ const Sampler2DArrayPrecision = {
26
+ name: 'sampler2darray-precision',
27
+ fs: '',
28
+ inject: {
29
+ 'fs:#decl': 'precision highp sampler2DArray;\n'
30
+ }
78
31
  };
79
- /** Interpolate a normalized value (0..1) into an RGB color from a ramp. */
80
- export function interpolateRamp(stops, t) {
81
- const n = stops.length - 1;
82
- const idx = Math.max(0, Math.min(n, t * n));
83
- const lo = Math.floor(idx);
84
- const hi = Math.min(lo + 1, n);
85
- const f = idx - lo;
86
- return [
87
- Math.round(stops[lo][0] + f * (stops[hi][0] - stops[lo][0])),
88
- Math.round(stops[lo][1] + f * (stops[hi][1] - stops[lo][1])),
89
- Math.round(stops[lo][2] + f * (stops[hi][2] - stops[lo][2]))
90
- ];
91
- }
92
- /** Generate a CSS linear-gradient string for a color ramp. */
93
- export function rampToGradientCss(id) {
94
- const stops = COLOR_RAMP_STOPS[id];
95
- const colors = stops.map((s, i) => `rgb(${s[0]},${s[1]},${s[2]}) ${((i / (stops.length - 1)) * 100).toFixed(0)}%`);
96
- return `linear-gradient(to right, ${colors.join(', ')})`;
97
- }
98
32
  /** Create a sensible default band config based on COG metadata. */
99
33
  export function defaultBandConfig(bandCount, sampleFormat) {
100
- if (bandCount >= 3) {
34
+ if (bandCount >= 3 && bandCount <= 4) {
101
35
  return {
102
36
  mode: 'rgb',
103
37
  rBand: 0,
@@ -150,6 +84,12 @@ export function needsCustomPipelineForConfig(geotiff, config) {
150
84
  const tags = geotiff.cachedTags;
151
85
  const sf = tags.sampleFormat;
152
86
  const isUint = sf !== null && sf[0] === 1;
87
+ // GPU textures accept 1-4 channels; COGs with more samples per pixel (embeddings,
88
+ // hyperspectral, multi-band features) must route through the CPU pipeline which
89
+ // reads selected band indices and bakes RGBA. Otherwise the library throws
90
+ // "Unsupported SamplesPerPixel N" in verifySamplesPerPixel.
91
+ if (geotiff.count > 4)
92
+ return true;
153
93
  if (!isUint)
154
94
  return true;
155
95
  // Palette-indexed uint COGs with an embedded ColorMap tag are auto-rendered
@@ -165,6 +105,12 @@ export function needsCustomPipelineForConfig(geotiff, config) {
165
105
  return true;
166
106
  if (config.rBand !== 0 || config.gBand !== 1 || config.bBand !== 2)
167
107
  return true;
108
+ // 4-band uint (e.g. NAIP RGB+NIR) must route through the CPU pipeline.
109
+ // The library default maps all 4 samples to RGBA, turning the extra band
110
+ // into a variable alpha channel even when it is not an alpha declaration.
111
+ // The custom pipeline explicitly picks 3 bands and bakes alpha=255.
112
+ if (geotiff.count === 4)
113
+ return true;
168
114
  return false;
169
115
  }
170
116
  export const DEFAULT_RESCALE = { min: 0, max: 1 };
@@ -215,6 +161,27 @@ export function createRescaledPipeline(geotiff, rescale) {
215
161
  }
216
162
  };
217
163
  }
164
+ /**
165
+ * Build a `renderPipeline` array for `MultiCOGLayer` / raster mosaics.
166
+ * Combines optional `FilterNoDataVal` + `LinearRescale` stages in the order
167
+ * the GPU expects (no-data mask first, then rescale).
168
+ */
169
+ export function buildBandRenderPipeline(opts = {}) {
170
+ const modules = [];
171
+ if (opts.noDataVal !== undefined && opts.noDataVal !== null) {
172
+ modules.push({
173
+ module: FilterNoDataVal,
174
+ props: { noDataVal: opts.noDataVal }
175
+ });
176
+ }
177
+ if (opts.rescale && isRescaleActive(opts.rescale)) {
178
+ modules.push({
179
+ module: LinearRescale,
180
+ props: { rescaleMin: opts.rescale.min, rescaleMax: opts.rescale.max }
181
+ });
182
+ }
183
+ return modules;
184
+ }
218
185
  // ─── GeoTIFF normalization for COGLayer ──────────────────────────
219
186
  // Web Mercator's safe latitude limit. EPSG:4326 bboxes outside ±85.051129° hit
220
187
  // out-of-domain proj4 NaN when the library generates its tile matrix set.
@@ -275,13 +242,17 @@ export function selectCogPipeline(geotiff, opts = {}) {
275
242
  if (useCustom && bandConfig) {
276
243
  return {
277
244
  getTileData: createConfigurableGetTileData(geotiff, bandConfig),
278
- renderTile: customRenderTile
245
+ renderTile: buildCustomRenderTile(bandConfig, rescale)
279
246
  };
280
247
  }
281
248
  if (useCustom) {
249
+ // Synthesize a single-band config so the GPU Colormap path still
250
+ // applies when a non-uint COG renders without a user-chosen ramp.
251
+ const fallbackSf = geotiff.cachedTags.sampleFormat?.[0] ?? 1;
252
+ const fallbackConfig = defaultBandConfig(geotiff.count, fallbackSf);
282
253
  return {
283
254
  getTileData: createCustomGetTileData(geotiff),
284
- renderTile: customRenderTile
255
+ renderTile: buildCustomRenderTile(fallbackConfig, rescale)
285
256
  };
286
257
  }
287
258
  if (rescale && isRescaleActive(rescale)) {
@@ -295,27 +266,9 @@ export function selectCogPipeline(geotiff, opts = {}) {
295
266
  }
296
267
  const BITMAP_SOURCE = 'geotiff-bitmap-src';
297
268
  const BITMAP_LAYER = 'geotiff-bitmap-layer';
298
- // ─── Pure helpers ────────────────────────────────────────────────
299
- /** Safely clamp a number to a range, treating NaN/Infinity as the fallback. */
300
- export function safeClamp(v, lo, hi, fallback) {
301
- return Number.isFinite(v) ? Math.max(lo, Math.min(hi, v)) : fallback;
302
- }
303
- /** Clamp geographic bounds to valid MapLibre web-Mercator range. */
304
- export function clampBounds(b) {
305
- return {
306
- west: safeClamp(b.west, -180, 180, -180),
307
- south: safeClamp(b.south, -85.051129, 85.051129, -85.051129),
308
- east: safeClamp(b.east, -180, 180, 180),
309
- north: safeClamp(b.north, -85.051129, 85.051129, 85.051129)
310
- };
311
- }
312
- /**
313
- * Build a data-type label from GeoTIFF sample format and bits per sample.
314
- * e.g. "uint8", "float32", "int16"
315
- */
316
- export function buildDataTypeLabel(sampleFormat, bitsPerSample) {
317
- return `${SF_LABELS[sampleFormat] ?? `sf${sampleFormat}`}${bitsPerSample ?? ''}`;
318
- }
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.
319
272
  // ─── Map helpers (depend on maplibre-gl) ─────────────────────────
320
273
  /**
321
274
  * Query the GPU's MAX_TEXTURE_SIZE from MapLibre's WebGL context.
@@ -624,12 +577,17 @@ export function needsCustomPipeline(geotiff) {
624
577
  // sampleFormat is null or not uint → needs custom
625
578
  return sf === null || sf[0] !== 1;
626
579
  }
580
+ /** Number of histogram buckets produced by the CPU bake. */
581
+ export const HISTOGRAM_BIN_COUNT = 64;
627
582
  /**
628
583
  * Create custom getTileData for non-uint COGs.
629
584
  * Reads band 0, normalizes using GDAL statistics / per-tile adaptive stretch,
630
- * applies terrain color ramp for single-band data.
585
+ * bakes a grayscale `r`-channel image so the GPU `Colormap` shader module
586
+ * (wired downstream by `selectCogPipeline`) can apply the ramp by sampling
587
+ * `colormaps.png`. Reserves `r = 0` for nodata so `FilterNoDataVal` can
588
+ * discard those fragments before the ramp sample.
631
589
  */
632
- export function createCustomGetTileData(geotiff) {
590
+ export function createCustomGetTileData(geotiff, _opts = {}) {
633
591
  // Read Scale/Offset TIFF tags (GDAL convention for scaled datasets like DEMs)
634
592
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
635
593
  const tags = geotiff.cachedTags;
@@ -656,26 +614,37 @@ export function createCustomGetTileData(geotiff) {
656
614
  globalMax = globalMax * (gdalScale ?? 1) + (gdalOffset ?? 0);
657
615
  }
658
616
  const bandCount = geotiff.count;
659
- const sf = tags.sampleFormat?.[0] ?? 1;
660
617
  const isSingleBand = bandCount === 1;
661
618
  // Shared range across all tiles — when no GDAL stats exist, the first
662
619
  // tile's scan seeds the range and subsequent tiles widen it. This
663
620
  // eliminates visible seams between tiles caused by per-tile normalization.
664
621
  let sharedMin = globalMin;
665
622
  let sharedMax = globalMax;
623
+ // Resolve the sprite texture from the first tile's device; reuse per-device.
624
+ let texturePromise = null;
666
625
  return async (image, options) => {
667
- const tile = await image.fetchTile(options.x, options.y, {
668
- boundless: false,
669
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
670
- pool: options.pool,
671
- signal: options.signal
672
- });
626
+ if (isSingleBand && !texturePromise) {
627
+ texturePromise = getColormapTexture(options.device);
628
+ }
629
+ const [tile, colormapTexture] = await Promise.all([
630
+ image.fetchTile(options.x, options.y, {
631
+ boundless: false,
632
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
633
+ pool: options.pool,
634
+ signal: options.signal
635
+ }),
636
+ texturePromise ?? Promise.resolve(undefined)
637
+ ]);
673
638
  const arr = tile.array;
674
639
  const { width, height, nodata } = arr;
675
640
  const bandData = arr.layout === 'band-separate' ? arr.bands[0] : arr.data;
676
641
  const pixelCount = width * height;
677
642
  const scale = gdalScale ?? 1;
678
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;
679
648
  // When no global stats, scan this tile and widen the shared range
680
649
  if (sharedMin === null || sharedMax === null) {
681
650
  let tMin = Infinity;
@@ -704,7 +673,10 @@ export function createCustomGetTileData(geotiff) {
704
673
  const rangeMin = sharedMin;
705
674
  const rangeMax = sharedMax;
706
675
  const range = rangeMax - rangeMin || 1;
707
- // Render to RGBA
676
+ // Render to RGBA. For single-band we bake normalized value into the
677
+ // `r` channel and reserve `r = 0` for nodata (see CustomTileData
678
+ // docs). Multi-band non-uint keeps the plain grayscale + α=255
679
+ // output so the default library pipeline consumes it unchanged.
708
680
  const rgba = new Uint8ClampedArray(pixelCount * 4);
709
681
  for (let i = 0; i < pixelCount; i++) {
710
682
  const raw = bandData[i];
@@ -719,15 +691,18 @@ export function createCustomGetTileData(geotiff) {
719
691
  }
720
692
  const v = hasScaleOffset ? raw * scale + offset : raw;
721
693
  const t = Math.max(0, Math.min(1, (v - rangeMin) / range));
722
- if (isSingleBand && (sf === 2 || sf === 3)) {
723
- // Terrain color ramp for single-band int/float (likely elevation/DEM)
724
- const [r, g, b] = terrainColor(t);
725
- rgba[idx] = r;
726
- rgba[idx + 1] = g;
727
- rgba[idx + 2] = b;
694
+ if (isSingleBand) {
695
+ // Reserve r=0 for nodata; valid data maps to [1, 255].
696
+ const gray = 1 + Math.round(t * 254);
697
+ rgba[idx] = gray;
698
+ rgba[idx + 1] = 0;
699
+ rgba[idx + 2] = 0;
700
+ if (histogram) {
701
+ const bin = Math.min(HISTOGRAM_BIN_COUNT - 1, Math.floor(t * HISTOGRAM_BIN_COUNT));
702
+ histogram[bin]++;
703
+ }
728
704
  }
729
705
  else {
730
- // Grayscale for multi-band or other types
731
706
  const gray = Math.round(t * 255);
732
707
  rgba[idx] = gray;
733
708
  rgba[idx + 1] = gray;
@@ -738,18 +713,57 @@ export function createCustomGetTileData(geotiff) {
738
713
  return {
739
714
  imageData: new ImageData(rgba, width, height),
740
715
  width,
741
- height
716
+ height,
717
+ colormapTexture: isSingleBand ? colormapTexture : undefined,
718
+ nodataSentinel: isSingleBand ? 0 : undefined,
719
+ histogram: histogram ?? undefined
742
720
  };
743
721
  };
744
722
  }
745
723
  /**
746
- * Custom renderTile for non-uint COGs.
747
- * v0.5 RasterLayer requires a RenderTileResult with `image` or `renderPipeline`.
748
- * We produce an ImageData and pass it through the `image` slot. deck.gl manages
749
- * the texture lifecycle and prepends a CreateTexture module automatically.
724
+ * Custom renderTile for COGs that use the CPU pipeline. For RGB mode (and
725
+ * legacy multi-band non-uint), the `image` slot carries a fully-baked RGBA
726
+ * `ImageData` and there is nothing to append on the GPU. For single-band
727
+ * mode, the image carries a normalized `r`-channel and this function
728
+ * appends `FilterNoDataVal` (to discard r=0 nodata sentinels), optional
729
+ * `LinearRescale` (brightness/contrast slider), and the sprite-based
730
+ * `Colormap` module so switching ramps is a uniform update — no tile
731
+ * re-decode required. The `colormapTexture` is stashed on `data` by the
732
+ * corresponding `getTileData` factory; if the sprite failed to resolve we
733
+ * fall back to the plain grayscale image.
750
734
  */
751
- export function customRenderTile(data) {
752
- return { image: data.imageData };
735
+ export function buildCustomRenderTile(config, rescale) {
736
+ return (data) => {
737
+ if (config.mode === 'rgb' || !data.colormapTexture) {
738
+ return { image: data.imageData };
739
+ }
740
+ const colormapIndex = COLORMAP_INDEX[config.colorRamp] ?? COLORMAP_INDEX.viridis;
741
+ const pipeline = [
742
+ {
743
+ module: FilterNoDataVal,
744
+ props: { value: (data.nodataSentinel ?? 0) / 255 }
745
+ }
746
+ ];
747
+ if (rescale && isRescaleActive(rescale)) {
748
+ pipeline.push({
749
+ module: LinearRescale,
750
+ props: { rescaleMin: rescale.min, rescaleMax: rescale.max }
751
+ });
752
+ }
753
+ pipeline.push(
754
+ // Precision shim must come before Colormap, its `fs:#decl` inject
755
+ // declares `precision highp sampler2DArray;` so the subsequent
756
+ // sampler uniform compiles on WebGL2 / Apple GPU.
757
+ { module: Sampler2DArrayPrecision, props: {} }, {
758
+ module: Colormap,
759
+ props: {
760
+ colormapTexture: data.colormapTexture,
761
+ colormapIndex,
762
+ reversed: false
763
+ }
764
+ });
765
+ return { image: data.imageData, renderPipeline: pipeline };
766
+ };
753
767
  }
754
768
  // ─── Configurable custom pipeline ────────────────────────────────
755
769
  /**
@@ -806,25 +820,40 @@ function computeBandRanges(bands, bandIndices, pixelCount, nodata) {
806
820
  }
807
821
  /**
808
822
  * Create a configurable getTileData that respects BandConfig.
809
- * Supports both RGB mode (multi-band → R,G,B) and single-band mode (color ramp).
823
+ * Supports RGB mode (multi-band → R,G,B with alpha=255, fully baked) and
824
+ * single-band mode (band N normalized into the `r` channel; the ramp is
825
+ * applied downstream by the GPU `Colormap` module via `buildCustomRenderTile`).
810
826
  */
811
- export function createConfigurableGetTileData(geotiff, config) {
827
+ export function createConfigurableGetTileData(geotiff, config, _opts = {}) {
812
828
  const bandCount = geotiff.count;
813
829
  // Shared per-band ranges across tiles (seeded on first tile, widened by subsequent)
814
830
  const sharedMins = new Map();
815
831
  const sharedMaxs = new Map();
832
+ // Resolve the sprite texture from the first tile's device; reuse per-device.
833
+ let texturePromise = null;
816
834
  return async (image, options) => {
817
- const tile = await image.fetchTile(options.x, options.y, {
818
- boundless: false,
819
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
820
- pool: options.pool,
821
- signal: options.signal
822
- });
835
+ if (config.mode === 'single' && !texturePromise) {
836
+ texturePromise = getColormapTexture(options.device);
837
+ }
838
+ const [tile, colormapTexture] = await Promise.all([
839
+ image.fetchTile(options.x, options.y, {
840
+ boundless: false,
841
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
842
+ pool: options.pool,
843
+ signal: options.signal
844
+ }),
845
+ texturePromise ?? Promise.resolve(undefined)
846
+ ]);
823
847
  const arr = tile.array;
824
848
  const { width, height, nodata } = arr;
825
849
  const pixelCount = width * height;
826
850
  const bands = extractBands(arr, bandCount, pixelCount);
827
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;
828
857
  if (config.mode === 'rgb') {
829
858
  // RGB mode: map 3 bands to R, G, B
830
859
  const indices = [config.rBand, config.gBand, config.bBand];
@@ -870,7 +899,9 @@ export function createConfigurableGetTileData(geotiff, config) {
870
899
  }
871
900
  }
872
901
  else {
873
- // Single-band mode: normalize + color ramp
902
+ // Single-band mode: normalize the selected band into the `r`
903
+ // channel and reserve `r = 0` as a nodata sentinel that
904
+ // `FilterNoDataVal` discards before the `Colormap` GPU lookup.
874
905
  const bi = config.band;
875
906
  const bandData = bands[bi];
876
907
  if (!sharedMins.has(bi) && bandData) {
@@ -881,7 +912,6 @@ export function createConfigurableGetTileData(geotiff, config) {
881
912
  const rangeMin = sharedMins.get(bi) ?? 0;
882
913
  const rangeMax = sharedMaxs.get(bi) ?? 1;
883
914
  const range = rangeMax - rangeMin || 1;
884
- const rampStops = COLOR_RAMP_STOPS[config.colorRamp];
885
915
  for (let i = 0; i < pixelCount; i++) {
886
916
  const raw = bandData?.[i] ?? 0;
887
917
  const isND = (nodata !== null && raw === nodata) || !Number.isFinite(raw);
@@ -894,15 +924,25 @@ export function createConfigurableGetTileData(geotiff, config) {
894
924
  }
895
925
  else {
896
926
  const t = Math.max(0, Math.min(1, (raw - rangeMin) / range));
897
- const [r, g, b] = interpolateRamp(rampStops, t);
898
- rgba[idx] = r;
899
- rgba[idx + 1] = g;
900
- rgba[idx + 2] = b;
927
+ rgba[idx] = 1 + Math.round(t * 254);
928
+ rgba[idx + 1] = 0;
929
+ rgba[idx + 2] = 0;
901
930
  rgba[idx + 3] = 255;
931
+ if (histogram) {
932
+ const bin = Math.min(HISTOGRAM_BIN_COUNT - 1, Math.floor(t * HISTOGRAM_BIN_COUNT));
933
+ histogram[bin]++;
934
+ }
902
935
  }
903
936
  }
904
937
  }
905
- return { imageData: new ImageData(rgba, width, height), width, height };
938
+ return {
939
+ imageData: new ImageData(rgba, width, height),
940
+ width,
941
+ height,
942
+ colormapTexture: config.mode === 'single' ? colormapTexture : undefined,
943
+ nodataSentinel: config.mode === 'single' ? 0 : undefined,
944
+ histogram: histogram ?? undefined
945
+ };
906
946
  };
907
947
  }
908
948
  // ─── EPSG resolution via bundled database ────────────────────────
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Shared loader for the `colormaps.png` sprite shipped by
3
+ * `@developmentseed/deck.gl-raster`. Decodes the sprite once per session and
4
+ * uploads it to the GPU once per `Device`, so every COG / mosaic / multicog
5
+ * viewer that renders a single-band colormap shares the same 2D-array
6
+ * texture.
7
+ *
8
+ * The sprite is ~16 KB. Decode is fire-and-forget on first touch and caches
9
+ * the resulting `ImageData` for the life of the tab; texture upload runs
10
+ * synchronously once the device is known. Switching color ramps becomes a
11
+ * uniform update (`colormapIndex`) rather than a texture rebind.
12
+ */
13
+ import { type ColormapName } from '@developmentseed/deck.gl-raster/gpu-modules';
14
+ import type { Device, Texture } from '@luma.gl/core';
15
+ export { COLORMAP_INDEX, type ColormapName } from '@developmentseed/deck.gl-raster/gpu-modules';
16
+ /** URL of the shipped sprite, consumable as a CSS `background-image`. */
17
+ export declare const COLORMAP_SPRITE_URL: any;
18
+ /** Number of distinct ramps encoded as 1-pixel-tall rows in the sprite. */
19
+ export declare const COLORMAP_SPRITE_LAYERS: number;
20
+ /** Width of each ramp row in pixels (also the sampling resolution). */
21
+ export declare const COLORMAP_SPRITE_WIDTH = 256;
22
+ /** All ramp names, sorted alphabetically (matches `COLORMAP_INDEX` key order). */
23
+ export declare const COLORMAP_NAMES: ColormapName[];
24
+ /** Decode the shipped sprite once per session. Safe to call repeatedly. */
25
+ export declare function loadColormapSprite(): Promise<ImageData>;
26
+ /**
27
+ * Get (or lazily build) the `sampler2DArray` colormap texture for a given
28
+ * device. Returns the same `Texture` on subsequent calls for the same device
29
+ * so it can be passed directly as the `colormapTexture` prop of the
30
+ * `Colormap` shader module.
31
+ */
32
+ export declare function getColormapTexture(device: Device): Promise<Texture>;
33
+ /**
34
+ * CSS `background` properties that render a single colormap row from the
35
+ * shipped sprite. Vertically scales the sprite so each 1-pixel row fills
36
+ * the container's full height, then offsets to land on the requested layer.
37
+ * Returns `undefined` for unknown ramp names so the caller can fall back.
38
+ */
39
+ export declare function spriteBackgroundStyle(name: ColormapName, heightPx: number): string | undefined;
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Shared loader for the `colormaps.png` sprite shipped by
3
+ * `@developmentseed/deck.gl-raster`. Decodes the sprite once per session and
4
+ * uploads it to the GPU once per `Device`, so every COG / mosaic / multicog
5
+ * viewer that renders a single-band colormap shares the same 2D-array
6
+ * texture.
7
+ *
8
+ * The sprite is ~16 KB. Decode is fire-and-forget on first touch and caches
9
+ * the resulting `ImageData` for the life of the tab; texture upload runs
10
+ * synchronously once the device is known. Switching color ramps becomes a
11
+ * uniform update (`colormapIndex`) rather than a texture rebind.
12
+ */
13
+ import { COLORMAP_INDEX, createColormapTexture, decodeColormapSprite } from '@developmentseed/deck.gl-raster/gpu-modules';
14
+ // The `?url` suffix makes Vite emit the PNG as a fingerprinted asset and
15
+ // hand us back a string URL. Bundlers like Rollup/webpack behave the same
16
+ // for this pattern; the build define in `vite.config.ts` already copies the
17
+ // PNG into the production bundle.
18
+ import colormapsPngUrl from '@developmentseed/deck.gl-raster/gpu-modules/colormaps.png?url';
19
+ export { COLORMAP_INDEX } from '@developmentseed/deck.gl-raster/gpu-modules';
20
+ /** URL of the shipped sprite, consumable as a CSS `background-image`. */
21
+ export const COLORMAP_SPRITE_URL = colormapsPngUrl;
22
+ /** Number of distinct ramps encoded as 1-pixel-tall rows in the sprite. */
23
+ export const COLORMAP_SPRITE_LAYERS = Object.keys(COLORMAP_INDEX).length;
24
+ /** Width of each ramp row in pixels (also the sampling resolution). */
25
+ export const COLORMAP_SPRITE_WIDTH = 256;
26
+ /** All ramp names, sorted alphabetically (matches `COLORMAP_INDEX` key order). */
27
+ export const COLORMAP_NAMES = Object.keys(COLORMAP_INDEX).sort();
28
+ let spritePromise = null;
29
+ const textureCache = new WeakMap();
30
+ /** Decode the shipped sprite once per session. Safe to call repeatedly. */
31
+ export function loadColormapSprite() {
32
+ if (!spritePromise) {
33
+ spritePromise = (async () => {
34
+ const res = await fetch(colormapsPngUrl);
35
+ if (!res.ok) {
36
+ throw new Error(`Failed to fetch colormaps.png: ${res.status}`);
37
+ }
38
+ const bytes = await res.arrayBuffer();
39
+ return decodeColormapSprite(bytes);
40
+ })();
41
+ }
42
+ return spritePromise;
43
+ }
44
+ /**
45
+ * Get (or lazily build) the `sampler2DArray` colormap texture for a given
46
+ * device. Returns the same `Texture` on subsequent calls for the same device
47
+ * so it can be passed directly as the `colormapTexture` prop of the
48
+ * `Colormap` shader module.
49
+ */
50
+ export async function getColormapTexture(device) {
51
+ const cached = textureCache.get(device);
52
+ if (cached)
53
+ return cached;
54
+ const imageData = await loadColormapSprite();
55
+ const texture = createColormapTexture(device, imageData);
56
+ textureCache.set(device, texture);
57
+ return texture;
58
+ }
59
+ /**
60
+ * CSS `background` properties that render a single colormap row from the
61
+ * shipped sprite. Vertically scales the sprite so each 1-pixel row fills
62
+ * the container's full height, then offsets to land on the requested layer.
63
+ * Returns `undefined` for unknown ramp names so the caller can fall back.
64
+ */
65
+ export function spriteBackgroundStyle(name, heightPx) {
66
+ const index = COLORMAP_INDEX[name];
67
+ if (index === undefined)
68
+ return undefined;
69
+ const totalHeight = COLORMAP_SPRITE_LAYERS * heightPx;
70
+ const yOffset = index * heightPx;
71
+ return [
72
+ `background-image: url("${COLORMAP_SPRITE_URL}")`,
73
+ 'background-repeat: no-repeat',
74
+ `background-size: 100% ${totalHeight}px`,
75
+ `background-position: 0 -${yOffset}px`
76
+ ].join('; ');
77
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Canonical connection identity.
3
+ *
4
+ * Single source of truth for deciding when two connection configs point at
5
+ * the same bucket. Used by the connections store to deduplicate auto-detect,
6
+ * manual add, and edit flows, so one physical bucket never ends up with
7
+ * multiple competing local records.
8
+ *
9
+ * Identity rules, per provider:
10
+ *
11
+ * azure → (provider, endpoint, bucket) endpoint carries the account
12
+ * gcs → (provider, bucket) GCS bucket names are global
13
+ * s3 → (provider, bucket, region) AWS native: same bucket name
14
+ * can exist in exactly one region,
15
+ * but the region is load-bearing
16
+ * for signing, so a paste with a
17
+ * different region is a distinct
18
+ * connection until the user merges
19
+ * other → (provider, endpoint, bucket) r2, b2, minio, wasabi, storj,
20
+ * digitalocean, contabo, hetzner,
21
+ * linode, ovhcloud, custom
22
+ *
23
+ * Endpoint normalization is aggressive: scheme + host + non-default port +
24
+ * pathname, with trailing slashes and default ports stripped, host lowercased.
25
+ * That collapses the common trip hazards — http vs https, :443 vs empty,
26
+ * trailing slash drift, mixed case host.
27
+ */
28
+ import type { ProviderId } from '../storage/providers.js';
29
+ export interface ConnectionIdentityInput {
30
+ provider: string;
31
+ endpoint: string;
32
+ bucket: string;
33
+ region: string;
34
+ }
35
+ /**
36
+ * Normalize an endpoint URL to a canonical form suitable for equality checks.
37
+ * Empty / whitespace-only input returns `''` (the "no endpoint" sentinel).
38
+ * Non-URL strings are lowercased and stripped of trailing slashes as a best
39
+ * effort so the comparison is still deterministic.
40
+ */
41
+ export declare function normalizeEndpoint(raw: string | undefined | null): string;
42
+ /** Collapse unknown / empty providers to `'s3'`; otherwise lowercase. */
43
+ export declare function normalizeProvider(provider: string | undefined | null): ProviderId;
44
+ /**
45
+ * Produce a canonical key for a connection's identity. Two connection
46
+ * configs with the same identity key point at the same physical bucket.
47
+ * Returns `''` when the config is too incomplete to identify a bucket.
48
+ */
49
+ export declare function connectionIdentityKey(input: ConnectionIdentityInput): string;
50
+ /** Convenience: true when both inputs share the same non-empty identity. */
51
+ export declare function isSameConnectionIdentity(a: ConnectionIdentityInput, b: ConnectionIdentityInput): boolean;