@gisatcz/deckgl-geolib 2.5.1-dev.1 → 2.6.0-dev.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/esm/index.js CHANGED
@@ -4967,6 +4967,8 @@ const DefaultGeoImageOptions = {
4967
4967
  useReliefGlaze: false,
4968
4968
  // --- Lighting control ---
4969
4969
  disableLighting: false,
4970
+ // --- Animation / Caching ---
4971
+ cacheAllBands: false,
4970
4972
  };
4971
4973
 
4972
4974
  class Martini {
@@ -7053,8 +7055,16 @@ class TileReader {
7053
7055
  }
7054
7056
  }
7055
7057
 
7058
+ // ── Global Multi-Band Cache ──
7059
+ // Survives across CogTiles instance recreations (e.g., during React re-renders).
7060
+ // Keyed by ${url}_${z}_${x}_${y}_${meshMaxError}_${skipTextureFlag}_band_${channel}
7061
+ // to account for dataset changes, tessellation options, and rendering modes.
7062
+ // Maps to pre-computed terrain meshes (same structure as single-band cache).
7063
+ const GLOBAL_MULTI_BAND_CACHE = new Map();
7056
7064
  const CogTilesGeoImageOptionsDefaults = {
7057
7065
  blurredTexture: true,
7066
+ // When true, log per-tile per-band min/max values for debugging (disabled by default)
7067
+ debugTileStats: false,
7058
7068
  };
7059
7069
  class CogTiles {
7060
7070
  cog;
@@ -7065,6 +7075,7 @@ class CogTiles {
7065
7075
  zoomRange = [0, 0];
7066
7076
  tileSize = 256;
7067
7077
  bounds = [0, 0, 0, 0];
7078
+ bandDescriptions = [];
7068
7079
  geo = new GeoImage();
7069
7080
  options;
7070
7081
  // TileResult cache — keyed by z/x/y/meshMaxError.
@@ -7093,6 +7104,14 @@ class CogTiles {
7093
7104
  // safely reinitialized against a different source.
7094
7105
  if (this.lastInitializedUrl !== undefined && this.lastInitializedUrl !== url) {
7095
7106
  this.cache.clearAll();
7107
+ // Clear multi-band cache entries for the old URL to prevent stale data
7108
+ const keysToDelete = [];
7109
+ GLOBAL_MULTI_BAND_CACHE.forEach((_, key) => {
7110
+ if (key.startsWith(this.lastInitializedUrl)) {
7111
+ keysToDelete.push(key);
7112
+ }
7113
+ });
7114
+ keysToDelete.forEach(key => GLOBAL_MULTI_BAND_CACHE.delete(key));
7096
7115
  this.cog = undefined;
7097
7116
  this.cogOrigin = [0, 0];
7098
7117
  this.cogZoomLookup = [];
@@ -7101,6 +7120,7 @@ class CogTiles {
7101
7120
  this.tileSize = 256;
7102
7121
  this.zoomRange = [0, 0];
7103
7122
  this.bounds = [0, 0, 0, 0];
7123
+ this.bandDescriptions = [];
7104
7124
  this.initializePromise = undefined;
7105
7125
  this.lastInitializedUrl = undefined;
7106
7126
  }
@@ -7126,6 +7146,31 @@ class CogTiles {
7126
7146
  this.options.format ??= await getDataTypeFromTags(fileDirectory);
7127
7147
  this.options.numOfChannels = fileDirectory.getValue('SamplesPerPixel');
7128
7148
  this.options.planarConfig = fileDirectory.getValue('PlanarConfiguration');
7149
+ // Load per-band descriptions from GDAL_METADATA tag
7150
+ const numBands = this.options.numOfChannels ?? 1;
7151
+ const descriptions = Array(numBands).fill('');
7152
+ if (image.fileDirectory.hasTag('GDAL_METADATA')) {
7153
+ const gdalMetadataStr = await image.fileDirectory.loadValue('GDAL_METADATA');
7154
+ // Parse XML GDAL metadata to extract per-band descriptions.
7155
+ // Regex is flexible with attribute order; GDAL generates items with name="DESCRIPTION"
7156
+ // before sample="N", but [^>]* allows other attributes to appear in any order.
7157
+ // Format: <Item name="DESCRIPTION" sample="0" role="description">20170101</Item>
7158
+ const bandDescRegex = /<Item[^>]*name="DESCRIPTION"[^>]*sample="(\d+)"[^>]*>([^<]*)<\/Item>/g;
7159
+ let match;
7160
+ while ((match = bandDescRegex.exec(gdalMetadataStr)) !== null) {
7161
+ const bandIdx = parseInt(match[1], 10);
7162
+ const desc = match[2];
7163
+ if (bandIdx < numBands) {
7164
+ descriptions[bandIdx] = desc;
7165
+ }
7166
+ }
7167
+ // Debug: Log if no descriptions were found in metadata
7168
+ if (descriptions.every(d => d === '')) {
7169
+ // eslint-disable-next-line no-console
7170
+ console.debug('[CogTiles] GDAL_METADATA present but no DESCRIPTION items found');
7171
+ }
7172
+ }
7173
+ this.bandDescriptions = descriptions;
7129
7174
  [this.cogZoomLookup, this.cogResolutionLookup] = await buildCogZoomResolutionLookup(this.cog);
7130
7175
  // Only compute quantized meshMaxError lookup for terrain COGs
7131
7176
  if (this.options.type === 'terrain') {
@@ -7164,9 +7209,23 @@ class CogTiles {
7164
7209
  return this.bounds;
7165
7210
  }
7166
7211
  /**
7167
- * Gets the auto meshMaxError for a given overview index.
7168
- * Returns undefined if auto lookup has not been computed.
7212
+ * Gets the number of channels/bands in the COG.
7213
+ * Returns the value from COG metadata, or 1 if not initialized.
7169
7214
  */
7215
+ getNumChannels() {
7216
+ return this.options.numOfChannels || 1;
7217
+ }
7218
+ /**
7219
+ * Returns per-band descriptions loaded from GDAL_METADATA during initialization.
7220
+ * Index is 0-based. Returns an empty string for bands without a description.
7221
+ */
7222
+ getBandDescriptions() {
7223
+ return this.bandDescriptions;
7224
+ }
7225
+ /**
7226
+ * Gets the auto meshMaxError for a given overview index.
7227
+ * Returns undefined if auto lookup has not been computed.
7228
+ */
7170
7229
  getMeshMaxErrorForImageIndex(imageIndex) {
7171
7230
  return this.cogMeshMaxErrorLookup[imageIndex];
7172
7231
  }
@@ -7374,6 +7433,54 @@ class CogTiles {
7374
7433
  }
7375
7434
  async getTerrainTile(x, y, z, bounds, resolvedMeshMaxError, cellSizeMeters, signal, skipTexture) {
7376
7435
  const skipTextureFlag = skipTexture ?? this.options.skipTexture ?? false;
7436
+ // ── Multi-Band Cache Check ──
7437
+ // If cacheAllBands is enabled, check if this tile+band combination is in the cache.
7438
+ // If so, return it instantly. If not, fetch all bands and populate the cache.
7439
+ if (this.options.cacheAllBands) {
7440
+ if (signal?.aborted) {
7441
+ return null; // Early exit if already aborted
7442
+ }
7443
+ const currentChannel = this.options.useChannel || 1; // 1-based
7444
+ // Cache key includes URL, meshMaxError, and skipTexture to account for dataset/option changes
7445
+ const multiBandCacheKey = `${this.lastInitializedUrl}_${z}_${x}_${y}_${resolvedMeshMaxError}_${skipTextureFlag}_band_${currentChannel}`;
7446
+ // Check if this specific band is already cached
7447
+ if (GLOBAL_MULTI_BAND_CACHE.has(multiBandCacheKey)) {
7448
+ const cached = GLOBAL_MULTI_BAND_CACHE.get(multiBandCacheKey);
7449
+ if (cached) {
7450
+ return cached;
7451
+ }
7452
+ }
7453
+ // Not in cache — fetch all bands for this tile and populate the cache
7454
+ try {
7455
+ // Pass NO signal to getTileAllBands to avoid early abortion
7456
+ // The tile lifecycle is managed by TileLayer, we want this fetch to complete
7457
+ const allBands = await this.getTileAllBands(x, y, z, resolvedMeshMaxError, undefined, bounds);
7458
+ // Only cache and return if we got valid data
7459
+ if (allBands && allBands.length > 0) {
7460
+ if (signal?.aborted) {
7461
+ return null; // Check abort after fetch completes
7462
+ }
7463
+ // Cache all bands (indexed by 1-based channel: band 1, band 2, ..., band N)
7464
+ allBands.forEach((bandResult, idx) => {
7465
+ const bandChannel = idx + 1; // Convert 0-based index to 1-based channel
7466
+ const cacheKey = `${this.lastInitializedUrl}_${z}_${x}_${y}_${resolvedMeshMaxError}_${skipTextureFlag}_band_${bandChannel}`;
7467
+ GLOBAL_MULTI_BAND_CACHE.set(cacheKey, bandResult);
7468
+ });
7469
+ // Return the requested band
7470
+ const requestedBandIndex = (currentChannel || 1) - 1; // Convert 1-based to 0-based
7471
+ if (requestedBandIndex < 0 || requestedBandIndex >= allBands.length) {
7472
+ console.error(`[CogTiles] Requested band index ${requestedBandIndex} out of range (0-${allBands.length - 1})`);
7473
+ return null;
7474
+ }
7475
+ return allBands[requestedBandIndex];
7476
+ }
7477
+ // If getTileAllBands returned empty, fall through to normal fetch
7478
+ }
7479
+ catch (error) {
7480
+ // If multi-band fetch fails, fall through to normal tile fetch
7481
+ console.warn('[CogTiles] Multi-band fetch failed, falling back to single-band:', error);
7482
+ }
7483
+ }
7377
7484
  const cacheKey = this.cache.getTileResultCacheKey(x, y, z, resolvedMeshMaxError, skipTextureFlag);
7378
7485
  const existing = this.cache.getTileResult(cacheKey);
7379
7486
  if (existing) {
@@ -7595,6 +7702,114 @@ class CogTiles {
7595
7702
  cellSizeMeters,
7596
7703
  }, this.options, meshMaxError ?? 4.0);
7597
7704
  }
7705
+ async getTileAllBands(x, y, z, meshMaxError, signal, bounds) {
7706
+ if (!this.cog) {
7707
+ return [];
7708
+ }
7709
+ // Compute cell size for this tile
7710
+ const latRad = Math.atan(Math.sinh(Math.PI * (1 - 2 * (y + 0.5) / Math.pow(2, z))));
7711
+ const tileWidthMeters = (EARTH_CIRCUMFERENCE / Math.pow(2, z)) * Math.cos(latRad);
7712
+ const cellSizeMeters = tileWidthMeters / this.tileSize;
7713
+ // Resolve meshMaxError
7714
+ let resolvedMeshMaxError;
7715
+ if (!meshMaxError || meshMaxError === 0) {
7716
+ const imageIndex = this.getImageIndexForZoomLevel(z);
7717
+ const autoMeshMaxError = this.getMeshMaxErrorForImageIndex(imageIndex);
7718
+ resolvedMeshMaxError = autoMeshMaxError ?? 4.0;
7719
+ }
7720
+ else {
7721
+ resolvedMeshMaxError = meshMaxError;
7722
+ }
7723
+ // Create a fresh local AbortController for this fetch
7724
+ const controller = new AbortController();
7725
+ if (signal && !signal.aborted) {
7726
+ signal.addEventListener('abort', () => controller.abort(), { once: true });
7727
+ }
7728
+ const localSignal = controller.signal;
7729
+ try {
7730
+ const imageIndex = this.getImageIndexForZoomLevel(z);
7731
+ let imagePromise = this.cache.getImage(imageIndex);
7732
+ if (!imagePromise) {
7733
+ imagePromise = this.cog.getImage(imageIndex);
7734
+ this.cache.setImage(imageIndex, imagePromise);
7735
+ }
7736
+ const targetImage = await imagePromise;
7737
+ const imageResolution = this.cogResolutionLookup[imageIndex];
7738
+ const imageHeight = targetImage.getHeight();
7739
+ const imageWidth = targetImage.getWidth();
7740
+ const [imgOriginX, imgOriginY] = this.cogOrigin;
7741
+ const TILE_SIZE = this.tileSize;
7742
+ const ORIGIN_X = webMercatorOrigin[0];
7743
+ const ORIGIN_Y = webMercatorOrigin[1];
7744
+ // ── Martini Grid Size Fix ──
7745
+ // Martini requires 2^n + 1 grid (e.g., 257x257), but we fetch at TILE_SIZE (256).
7746
+ // Add padding for relief kernels (2) or just 1 for basic terrain.
7747
+ const isKernel = this.options.useSlope || this.options.useHillshade || this.options.useSwissRelief;
7748
+ const FETCH_SIZE = this.tileSize + (isKernel ? 2 : 1);
7749
+ const tileGridResolution = (EARTH_CIRCUMFERENCE / TILE_SIZE) / (2 ** z);
7750
+ const tileMinXMeters = ORIGIN_X + (x * TILE_SIZE * tileGridResolution);
7751
+ const tileMaxYMeters = ORIGIN_Y - (y * TILE_SIZE * tileGridResolution);
7752
+ const windowMinX = (tileMinXMeters - imgOriginX) / imageResolution;
7753
+ const windowMinY = (imgOriginY - tileMaxYMeters) / imageResolution;
7754
+ const startX = Math.round(windowMinX);
7755
+ const startY = Math.round(windowMinY);
7756
+ const endX = startX + FETCH_SIZE;
7757
+ const endY = startY + FETCH_SIZE;
7758
+ const validReadX = Math.max(0, startX);
7759
+ const validReadY = Math.max(0, startY);
7760
+ const validReadMaxX = Math.min(imageWidth, endX);
7761
+ const validReadMaxY = Math.min(imageHeight, endY);
7762
+ const readWidth = validReadMaxX - validReadX;
7763
+ const readHeight = validReadMaxY - validReadY;
7764
+ // If no overlap, return empty array
7765
+ if (readWidth <= 0 || readHeight <= 0) {
7766
+ return [];
7767
+ }
7768
+ const missingLeft = validReadX - startX;
7769
+ const missingTop = validReadY - startY;
7770
+ const window = [validReadX, validReadY, validReadMaxX, validReadMaxY];
7771
+ const validRasterData = await targetImage.readRasters({ window, signal: localSignal });
7772
+ if (signal?.aborted)
7773
+ return [];
7774
+ const results = [];
7775
+ const numBands = validRasterData.length;
7776
+ for (let bandIndex = 0; bandIndex < numBands; bandIndex += 1) {
7777
+ const sourceBandArray = validRasterData[bandIndex];
7778
+ const processedBandRaster = this.tileReader.createTileBuffer(this.options.format || 'Float32', FETCH_SIZE);
7779
+ if (this.options.noDataValue !== undefined) {
7780
+ processedBandRaster.fill(this.options.noDataValue);
7781
+ }
7782
+ for (let row = 0; row < readHeight; row += 1) {
7783
+ const destRow = missingTop + row;
7784
+ const destRowOffset = destRow * FETCH_SIZE;
7785
+ const srcRowOffset = row * readWidth;
7786
+ for (let col = 0; col < readWidth; col += 1) {
7787
+ const destCol = missingLeft + col;
7788
+ if (destRow < FETCH_SIZE && destCol < FETCH_SIZE) {
7789
+ processedBandRaster[destRowOffset + destCol] = sourceBandArray[srcRowOffset + col];
7790
+ }
7791
+ }
7792
+ }
7793
+ const generatorOptions = { ...this.options, useChannel: 1, useChannelIndex: 0, numOfChannels: 1 };
7794
+ const tileResult = await this.geo.getMap({
7795
+ rasters: [processedBandRaster],
7796
+ width: FETCH_SIZE,
7797
+ height: FETCH_SIZE,
7798
+ bounds: bounds ?? [0, 0, 0, 0],
7799
+ cellSizeMeters,
7800
+ }, generatorOptions, resolvedMeshMaxError);
7801
+ if (tileResult)
7802
+ results.push(tileResult);
7803
+ }
7804
+ return results;
7805
+ }
7806
+ catch (error) {
7807
+ if (signal?.aborted)
7808
+ return [];
7809
+ console.error('[CogTiles.getTileAllBands] Error fetching all bands:', error);
7810
+ return [];
7811
+ }
7812
+ }
7598
7813
  // Expose legacy API for clearing tile result cache (used by external layers)
7599
7814
  clearTileResultCache() {
7600
7815
  this.cache.clearTileResultCache();