@gisatcz/deckgl-geolib 2.5.1-dev.1 → 2.6.0-dev.2

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/cjs/index.js CHANGED
@@ -4969,6 +4969,8 @@ const DefaultGeoImageOptions = {
4969
4969
  useReliefGlaze: false,
4970
4970
  // --- Lighting control ---
4971
4971
  disableLighting: false,
4972
+ // --- Animation / Caching ---
4973
+ cacheAllBands: false,
4972
4974
  };
4973
4975
 
4974
4976
  class Martini {
@@ -7055,8 +7057,16 @@ class TileReader {
7055
7057
  }
7056
7058
  }
7057
7059
 
7060
+ // ── Global Multi-Band Cache ──
7061
+ // Survives across CogTiles instance recreations (e.g., during React re-renders).
7062
+ // Keyed by ${url}_${z}_${x}_${y}_${meshMaxError}_${skipTextureFlag}_band_${channel}
7063
+ // to account for dataset changes, tessellation options, and rendering modes.
7064
+ // Maps to pre-computed terrain meshes (same structure as single-band cache).
7065
+ const GLOBAL_MULTI_BAND_CACHE = new Map();
7058
7066
  const CogTilesGeoImageOptionsDefaults = {
7059
7067
  blurredTexture: true,
7068
+ // When true, log per-tile per-band min/max values for debugging (disabled by default)
7069
+ debugTileStats: false,
7060
7070
  };
7061
7071
  class CogTiles {
7062
7072
  cog;
@@ -7067,6 +7077,7 @@ class CogTiles {
7067
7077
  zoomRange = [0, 0];
7068
7078
  tileSize = 256;
7069
7079
  bounds = [0, 0, 0, 0];
7080
+ bandDescriptions = [];
7070
7081
  geo = new GeoImage();
7071
7082
  options;
7072
7083
  // TileResult cache — keyed by z/x/y/meshMaxError.
@@ -7095,6 +7106,14 @@ class CogTiles {
7095
7106
  // safely reinitialized against a different source.
7096
7107
  if (this.lastInitializedUrl !== undefined && this.lastInitializedUrl !== url) {
7097
7108
  this.cache.clearAll();
7109
+ // Clear multi-band cache entries for the old URL to prevent stale data
7110
+ const keysToDelete = [];
7111
+ GLOBAL_MULTI_BAND_CACHE.forEach((_, key) => {
7112
+ if (key.startsWith(this.lastInitializedUrl)) {
7113
+ keysToDelete.push(key);
7114
+ }
7115
+ });
7116
+ keysToDelete.forEach(key => GLOBAL_MULTI_BAND_CACHE.delete(key));
7098
7117
  this.cog = undefined;
7099
7118
  this.cogOrigin = [0, 0];
7100
7119
  this.cogZoomLookup = [];
@@ -7103,6 +7122,7 @@ class CogTiles {
7103
7122
  this.tileSize = 256;
7104
7123
  this.zoomRange = [0, 0];
7105
7124
  this.bounds = [0, 0, 0, 0];
7125
+ this.bandDescriptions = [];
7106
7126
  this.initializePromise = undefined;
7107
7127
  this.lastInitializedUrl = undefined;
7108
7128
  }
@@ -7128,6 +7148,31 @@ class CogTiles {
7128
7148
  this.options.format ??= await getDataTypeFromTags(fileDirectory);
7129
7149
  this.options.numOfChannels = fileDirectory.getValue('SamplesPerPixel');
7130
7150
  this.options.planarConfig = fileDirectory.getValue('PlanarConfiguration');
7151
+ // Load per-band descriptions from GDAL_METADATA tag
7152
+ const numBands = this.options.numOfChannels ?? 1;
7153
+ const descriptions = Array(numBands).fill('');
7154
+ if (image.fileDirectory.hasTag('GDAL_METADATA')) {
7155
+ const gdalMetadataStr = await image.fileDirectory.loadValue('GDAL_METADATA');
7156
+ // Parse XML GDAL metadata to extract per-band descriptions.
7157
+ // Regex is flexible with attribute order; GDAL generates items with name="DESCRIPTION"
7158
+ // before sample="N", but [^>]* allows other attributes to appear in any order.
7159
+ // Format: <Item name="DESCRIPTION" sample="0" role="description">20170101</Item>
7160
+ const bandDescRegex = /<Item[^>]*name="DESCRIPTION"[^>]*sample="(\d+)"[^>]*>([^<]*)<\/Item>/g;
7161
+ let match;
7162
+ while ((match = bandDescRegex.exec(gdalMetadataStr)) !== null) {
7163
+ const bandIdx = parseInt(match[1], 10);
7164
+ const desc = match[2];
7165
+ if (bandIdx < numBands) {
7166
+ descriptions[bandIdx] = desc;
7167
+ }
7168
+ }
7169
+ // Debug: Log if no descriptions were found in metadata
7170
+ if (descriptions.every(d => d === '')) {
7171
+ // eslint-disable-next-line no-console
7172
+ console.debug('[CogTiles] GDAL_METADATA present but no DESCRIPTION items found');
7173
+ }
7174
+ }
7175
+ this.bandDescriptions = descriptions;
7131
7176
  [this.cogZoomLookup, this.cogResolutionLookup] = await buildCogZoomResolutionLookup(this.cog);
7132
7177
  // Only compute quantized meshMaxError lookup for terrain COGs
7133
7178
  if (this.options.type === 'terrain') {
@@ -7166,9 +7211,23 @@ class CogTiles {
7166
7211
  return this.bounds;
7167
7212
  }
7168
7213
  /**
7169
- * Gets the auto meshMaxError for a given overview index.
7170
- * Returns undefined if auto lookup has not been computed.
7214
+ * Gets the number of channels/bands in the COG.
7215
+ * Returns the value from COG metadata, or 1 if not initialized.
7216
+ */
7217
+ getNumChannels() {
7218
+ return this.options.numOfChannels || 1;
7219
+ }
7220
+ /**
7221
+ * Returns per-band descriptions loaded from GDAL_METADATA during initialization.
7222
+ * Index is 0-based. Returns an empty string for bands without a description.
7171
7223
  */
7224
+ getBandDescriptions() {
7225
+ return this.bandDescriptions;
7226
+ }
7227
+ /**
7228
+ * Gets the auto meshMaxError for a given overview index.
7229
+ * Returns undefined if auto lookup has not been computed.
7230
+ */
7172
7231
  getMeshMaxErrorForImageIndex(imageIndex) {
7173
7232
  return this.cogMeshMaxErrorLookup[imageIndex];
7174
7233
  }
@@ -7376,6 +7435,54 @@ class CogTiles {
7376
7435
  }
7377
7436
  async getTerrainTile(x, y, z, bounds, resolvedMeshMaxError, cellSizeMeters, signal, skipTexture) {
7378
7437
  const skipTextureFlag = skipTexture ?? this.options.skipTexture ?? false;
7438
+ // ── Multi-Band Cache Check ──
7439
+ // If cacheAllBands is enabled, check if this tile+band combination is in the cache.
7440
+ // If so, return it instantly. If not, fetch all bands and populate the cache.
7441
+ if (this.options.cacheAllBands) {
7442
+ if (signal?.aborted) {
7443
+ return null; // Early exit if already aborted
7444
+ }
7445
+ const currentChannel = this.options.useChannel || 1; // 1-based
7446
+ // Cache key includes URL, meshMaxError, and skipTexture to account for dataset/option changes
7447
+ const multiBandCacheKey = `${this.lastInitializedUrl}_${z}_${x}_${y}_${resolvedMeshMaxError}_${skipTextureFlag}_band_${currentChannel}`;
7448
+ // Check if this specific band is already cached
7449
+ if (GLOBAL_MULTI_BAND_CACHE.has(multiBandCacheKey)) {
7450
+ const cached = GLOBAL_MULTI_BAND_CACHE.get(multiBandCacheKey);
7451
+ if (cached) {
7452
+ return cached;
7453
+ }
7454
+ }
7455
+ // Not in cache — fetch all bands for this tile and populate the cache
7456
+ try {
7457
+ // Pass NO signal to getTileAllBands to avoid early abortion
7458
+ // The tile lifecycle is managed by TileLayer, we want this fetch to complete
7459
+ const allBands = await this.getTileAllBands(x, y, z, resolvedMeshMaxError, undefined, bounds);
7460
+ // Only cache and return if we got valid data
7461
+ if (allBands && allBands.length > 0) {
7462
+ if (signal?.aborted) {
7463
+ return null; // Check abort after fetch completes
7464
+ }
7465
+ // Cache all bands (indexed by 1-based channel: band 1, band 2, ..., band N)
7466
+ allBands.forEach((bandResult, idx) => {
7467
+ const bandChannel = idx + 1; // Convert 0-based index to 1-based channel
7468
+ const cacheKey = `${this.lastInitializedUrl}_${z}_${x}_${y}_${resolvedMeshMaxError}_${skipTextureFlag}_band_${bandChannel}`;
7469
+ GLOBAL_MULTI_BAND_CACHE.set(cacheKey, bandResult);
7470
+ });
7471
+ // Return the requested band
7472
+ const requestedBandIndex = (currentChannel || 1) - 1; // Convert 1-based to 0-based
7473
+ if (requestedBandIndex < 0 || requestedBandIndex >= allBands.length) {
7474
+ console.error(`[CogTiles] Requested band index ${requestedBandIndex} out of range (0-${allBands.length - 1})`);
7475
+ return null;
7476
+ }
7477
+ return allBands[requestedBandIndex];
7478
+ }
7479
+ // If getTileAllBands returned empty, fall through to normal fetch
7480
+ }
7481
+ catch (error) {
7482
+ // If multi-band fetch fails, fall through to normal tile fetch
7483
+ console.warn('[CogTiles] Multi-band fetch failed, falling back to single-band:', error);
7484
+ }
7485
+ }
7379
7486
  const cacheKey = this.cache.getTileResultCacheKey(x, y, z, resolvedMeshMaxError, skipTextureFlag);
7380
7487
  const existing = this.cache.getTileResult(cacheKey);
7381
7488
  if (existing) {
@@ -7597,6 +7704,114 @@ class CogTiles {
7597
7704
  cellSizeMeters,
7598
7705
  }, this.options, meshMaxError ?? 4.0);
7599
7706
  }
7707
+ async getTileAllBands(x, y, z, meshMaxError, signal, bounds) {
7708
+ if (!this.cog) {
7709
+ return [];
7710
+ }
7711
+ // Compute cell size for this tile
7712
+ const latRad = Math.atan(Math.sinh(Math.PI * (1 - 2 * (y + 0.5) / Math.pow(2, z))));
7713
+ const tileWidthMeters = (EARTH_CIRCUMFERENCE / Math.pow(2, z)) * Math.cos(latRad);
7714
+ const cellSizeMeters = tileWidthMeters / this.tileSize;
7715
+ // Resolve meshMaxError
7716
+ let resolvedMeshMaxError;
7717
+ if (!meshMaxError || meshMaxError === 0) {
7718
+ const imageIndex = this.getImageIndexForZoomLevel(z);
7719
+ const autoMeshMaxError = this.getMeshMaxErrorForImageIndex(imageIndex);
7720
+ resolvedMeshMaxError = autoMeshMaxError ?? 4.0;
7721
+ }
7722
+ else {
7723
+ resolvedMeshMaxError = meshMaxError;
7724
+ }
7725
+ // Create a fresh local AbortController for this fetch
7726
+ const controller = new AbortController();
7727
+ if (signal && !signal.aborted) {
7728
+ signal.addEventListener('abort', () => controller.abort(), { once: true });
7729
+ }
7730
+ const localSignal = controller.signal;
7731
+ try {
7732
+ const imageIndex = this.getImageIndexForZoomLevel(z);
7733
+ let imagePromise = this.cache.getImage(imageIndex);
7734
+ if (!imagePromise) {
7735
+ imagePromise = this.cog.getImage(imageIndex);
7736
+ this.cache.setImage(imageIndex, imagePromise);
7737
+ }
7738
+ const targetImage = await imagePromise;
7739
+ const imageResolution = this.cogResolutionLookup[imageIndex];
7740
+ const imageHeight = targetImage.getHeight();
7741
+ const imageWidth = targetImage.getWidth();
7742
+ const [imgOriginX, imgOriginY] = this.cogOrigin;
7743
+ const TILE_SIZE = this.tileSize;
7744
+ const ORIGIN_X = webMercatorOrigin[0];
7745
+ const ORIGIN_Y = webMercatorOrigin[1];
7746
+ // ── Martini Grid Size Fix ──
7747
+ // Martini requires 2^n + 1 grid (e.g., 257x257), but we fetch at TILE_SIZE (256).
7748
+ // Add padding for relief kernels (2) or just 1 for basic terrain.
7749
+ const isKernel = this.options.useSlope || this.options.useHillshade || this.options.useSwissRelief;
7750
+ const FETCH_SIZE = this.tileSize + (isKernel ? 2 : 1);
7751
+ const tileGridResolution = (EARTH_CIRCUMFERENCE / TILE_SIZE) / (2 ** z);
7752
+ const tileMinXMeters = ORIGIN_X + (x * TILE_SIZE * tileGridResolution);
7753
+ const tileMaxYMeters = ORIGIN_Y - (y * TILE_SIZE * tileGridResolution);
7754
+ const windowMinX = (tileMinXMeters - imgOriginX) / imageResolution;
7755
+ const windowMinY = (imgOriginY - tileMaxYMeters) / imageResolution;
7756
+ const startX = Math.round(windowMinX);
7757
+ const startY = Math.round(windowMinY);
7758
+ const endX = startX + FETCH_SIZE;
7759
+ const endY = startY + FETCH_SIZE;
7760
+ const validReadX = Math.max(0, startX);
7761
+ const validReadY = Math.max(0, startY);
7762
+ const validReadMaxX = Math.min(imageWidth, endX);
7763
+ const validReadMaxY = Math.min(imageHeight, endY);
7764
+ const readWidth = validReadMaxX - validReadX;
7765
+ const readHeight = validReadMaxY - validReadY;
7766
+ // If no overlap, return empty array
7767
+ if (readWidth <= 0 || readHeight <= 0) {
7768
+ return [];
7769
+ }
7770
+ const missingLeft = validReadX - startX;
7771
+ const missingTop = validReadY - startY;
7772
+ const window = [validReadX, validReadY, validReadMaxX, validReadMaxY];
7773
+ const validRasterData = await targetImage.readRasters({ window, signal: localSignal });
7774
+ if (signal?.aborted)
7775
+ return [];
7776
+ const results = [];
7777
+ const numBands = validRasterData.length;
7778
+ for (let bandIndex = 0; bandIndex < numBands; bandIndex += 1) {
7779
+ const sourceBandArray = validRasterData[bandIndex];
7780
+ const processedBandRaster = this.tileReader.createTileBuffer(this.options.format || 'Float32', FETCH_SIZE);
7781
+ if (this.options.noDataValue !== undefined) {
7782
+ processedBandRaster.fill(this.options.noDataValue);
7783
+ }
7784
+ for (let row = 0; row < readHeight; row += 1) {
7785
+ const destRow = missingTop + row;
7786
+ const destRowOffset = destRow * FETCH_SIZE;
7787
+ const srcRowOffset = row * readWidth;
7788
+ for (let col = 0; col < readWidth; col += 1) {
7789
+ const destCol = missingLeft + col;
7790
+ if (destRow < FETCH_SIZE && destCol < FETCH_SIZE) {
7791
+ processedBandRaster[destRowOffset + destCol] = sourceBandArray[srcRowOffset + col];
7792
+ }
7793
+ }
7794
+ }
7795
+ const generatorOptions = { ...this.options, useChannel: 1, useChannelIndex: 0, numOfChannels: 1 };
7796
+ const tileResult = await this.geo.getMap({
7797
+ rasters: [processedBandRaster],
7798
+ width: FETCH_SIZE,
7799
+ height: FETCH_SIZE,
7800
+ bounds: bounds ?? [0, 0, 0, 0],
7801
+ cellSizeMeters,
7802
+ }, generatorOptions, resolvedMeshMaxError);
7803
+ if (tileResult)
7804
+ results.push(tileResult);
7805
+ }
7806
+ return results;
7807
+ }
7808
+ catch (error) {
7809
+ if (signal?.aborted)
7810
+ return [];
7811
+ console.error('[CogTiles.getTileAllBands] Error fetching all bands:', error);
7812
+ return [];
7813
+ }
7814
+ }
7600
7815
  // Expose legacy API for clearing tile result cache (used by external layers)
7601
7816
  clearTileResultCache() {
7602
7817
  this.cache.clearTileResultCache();
@@ -8097,6 +8312,151 @@ class CogTerrainLayer extends core.CompositeLayer {
8097
8312
  }
8098
8313
  }
8099
8314
 
8315
+ /**
8316
+ * Terrain coordinate extraction utility for CogTerrainLayer
8317
+ * Enables precise lat/lon/elevation extraction from 3D terrain picks
8318
+ */
8319
+ /**
8320
+ * Extracts precise geographic coordinates and elevation from a CogTerrainLayer pick result
8321
+ *
8322
+ * @param pickResult - DeckGL pickObject result from terrain-layer pick
8323
+ * @returns TerrainCoordinate with lon/lat/elevation, or null if extraction fails
8324
+ *
8325
+ * @requires deck.gl >=9.3.0 (for `pickable: '3d'` support)
8326
+ * @note Requires `pickable: '3d'` on CogTerrainLayer. With 3D picking enabled,
8327
+ * deck.gl's terrain layer provides info.coordinate as a 3-element array [lon, lat, elevation]
8328
+ * where elevation is read directly from the terrain mesh at the picked point.
8329
+ * This gives accurate 3D coordinates regardless of camera pitch or bearing.
8330
+ *
8331
+ * @example
8332
+ * ```ts
8333
+ * const cogLayer = new CogTerrainLayer({
8334
+ * // ...
8335
+ * pickable: '3d', // Requires deck.gl >=9.3.0
8336
+ * onClick: (info) => {
8337
+ * const coord = extractTerrainCoordinate(info);
8338
+ * if (coord) {
8339
+ * console.log(`Clicked at ${coord.latitude}, ${coord.longitude}, elevation: ${coord.elevation}m`);
8340
+ * }
8341
+ * }
8342
+ * });
8343
+ * ```
8344
+ */
8345
+ function extractTerrainCoordinate(pickResult) {
8346
+ try {
8347
+ // With pickable: '3d', info.coordinate is a 3-element array [lon, lat, elevation]
8348
+ if (!pickResult?.coordinate || pickResult.coordinate.length < 3) {
8349
+ return null;
8350
+ }
8351
+ const [longitude, latitude, elevation] = pickResult.coordinate;
8352
+ if (longitude === undefined || latitude === undefined || elevation === undefined) {
8353
+ return null;
8354
+ }
8355
+ return {
8356
+ longitude,
8357
+ latitude,
8358
+ elevation,
8359
+ };
8360
+ }
8361
+ catch {
8362
+ // Silently return null on any error
8363
+ return null;
8364
+ }
8365
+ }
8366
+ /**
8367
+ * Samples terrain coordinates in a grid around a pick point for debugging
8368
+ * Useful for understanding terrain data layout and accuracy
8369
+ *
8370
+ * @param pickResult - DeckGL pickObject result from terrain-layer pick
8371
+ * @param gridSize - Odd number for grid dimensions (default: 3 for 3x3 grid).
8372
+ * gridSize=3 → 3×3 grid (offset±1), gridSize=5 → 5×5 grid (offset±2).
8373
+ * Uses WebMercator projection for accurate latitude mapping.
8374
+ * @returns Array of TerrainCoordinate samples, or empty array if extraction fails
8375
+ *
8376
+ * @example
8377
+ * ```ts
8378
+ * const samples = sampleTerrainTileCoordinates(info, 5); // 5x5 grid around click
8379
+ * samples.forEach(coord => {
8380
+ * console.log(`Sample: ${coord.latitude}, ${coord.longitude}, elev: ${coord.elevation}m`);
8381
+ * });
8382
+ * ```
8383
+ */
8384
+ function sampleTerrainTileCoordinates(pickResult, gridSize = 3) {
8385
+ try {
8386
+ // Validate input has required structure
8387
+ if (!pickResult?.tile?.content) {
8388
+ return [];
8389
+ }
8390
+ const tileResult = pickResult.tile.content[0];
8391
+ if (!tileResult?.raw) {
8392
+ return [];
8393
+ }
8394
+ const { raw, width, height } = tileResult;
8395
+ const bbox = pickResult.tile.bbox;
8396
+ if (!bbox) {
8397
+ return [];
8398
+ }
8399
+ const west = bbox.west ?? bbox[0];
8400
+ const south = bbox.south ?? bbox[1];
8401
+ const east = bbox.east ?? bbox[2];
8402
+ const north = bbox.north ?? bbox[3];
8403
+ if (west === undefined || south === undefined || east === undefined || north === undefined) {
8404
+ return [];
8405
+ }
8406
+ const coordinate = pickResult.coordinate;
8407
+ if (!coordinate || coordinate.length < 2) {
8408
+ return [];
8409
+ }
8410
+ const [centerLon, centerLat] = coordinate;
8411
+ // Calculate grid offset (in pixels): gridSize must be odd; gridSize=3 → offset=1, gridSize=5 → offset=2
8412
+ const offset = Math.floor(gridSize / 2);
8413
+ // Get center pixel from clicked coordinate using WebMercator projection
8414
+ const centerNormX = (centerLon - west) / (east - west);
8415
+ // WebMercator non-linear latitude projection
8416
+ const centerLatRad = centerLat * Math.PI / 180;
8417
+ const northRad = north * Math.PI / 180;
8418
+ const southRad = south * Math.PI / 180;
8419
+ const mercatorCenterY = Math.log(Math.tan(Math.PI / 4 + centerLatRad / 2));
8420
+ const mercatorNorth = Math.log(Math.tan(Math.PI / 4 + northRad / 2));
8421
+ const mercatorSouth = Math.log(Math.tan(Math.PI / 4 + southRad / 2));
8422
+ const centerNormY = (mercatorNorth - mercatorCenterY) / (mercatorNorth - mercatorSouth);
8423
+ const centerPixelX = Math.floor(centerNormX * (width - 1));
8424
+ const centerPixelY = Math.floor(centerNormY * (height - 1));
8425
+ const samples = [];
8426
+ // Sample grid around clicked point
8427
+ for (let dy = -offset; dy <= offset; dy++) {
8428
+ for (let dx = -offset; dx <= offset; dx++) {
8429
+ const pixelX = centerPixelX + dx;
8430
+ const pixelY = centerPixelY + dy;
8431
+ // Stay within tile bounds
8432
+ if (pixelX < 0 || pixelX >= width || pixelY < 0 || pixelY >= height) {
8433
+ continue;
8434
+ }
8435
+ const pixelIndex = pixelY * width + pixelX;
8436
+ const elevation = raw[pixelIndex];
8437
+ if (elevation === undefined || elevation === null) {
8438
+ continue;
8439
+ }
8440
+ // Convert pixel to geographic coordinates using WebMercator projection
8441
+ const lon = west + (pixelX / (width - 1)) * (east - west);
8442
+ // Inverse WebMercator transform for latitude
8443
+ const normV = pixelY / (height - 1);
8444
+ const mercatorY = mercatorNorth - normV * (mercatorNorth - mercatorSouth);
8445
+ const lat = (2 * Math.atan(Math.exp(mercatorY)) - Math.PI / 2) * 180 / Math.PI;
8446
+ samples.push({
8447
+ longitude: lon,
8448
+ latitude: lat,
8449
+ elevation,
8450
+ });
8451
+ }
8452
+ }
8453
+ return samples;
8454
+ }
8455
+ catch {
8456
+ return [];
8457
+ }
8458
+ }
8459
+
8100
8460
  // src/index.ts
8101
8461
  // Initialize global error suppression for deck.gl AbortErrors
8102
8462
  suppressGlobalAbortErrors();
@@ -15314,5 +15674,7 @@ exports.CogBitmapLayer = CogBitmapLayer;
15314
15674
  exports.CogTerrainLayer = CogTerrainLayer;
15315
15675
  exports.CogTiles = CogTiles;
15316
15676
  exports.GeoImage = GeoImage;
15677
+ exports.extractTerrainCoordinate = extractTerrainCoordinate;
15678
+ exports.sampleTerrainTileCoordinates = sampleTerrainTileCoordinates;
15317
15679
  exports.suppressGlobalAbortErrors = suppressGlobalAbortErrors;
15318
15680
  //# sourceMappingURL=index.js.map