@gisatcz/deckgl-geolib 2.5.0 → 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 {
@@ -6736,8 +6738,333 @@ const EARTH_CIRCUMFERENCE = 2 * Math.PI * 6378137;
6736
6738
  const EARTH_HALF_CIRCUMFERENCE = EARTH_CIRCUMFERENCE / 2;
6737
6739
  const webMercatorOrigin = [-20037508.342789244, 20037508.342789244];
6738
6740
  const webMercatorRes0 = 156543.03125;
6741
+ function getLatLon(input) {
6742
+ const x = input[0];
6743
+ const y = input[1];
6744
+ const lon = (x / EARTH_HALF_CIRCUMFERENCE) * 180;
6745
+ let lat = (y / EARTH_HALF_CIRCUMFERENCE) * 180;
6746
+ lat = (180 / Math.PI) * (2 * Math.atan(Math.exp((lat * Math.PI) / 180)) - Math.PI / 2);
6747
+ return [lon, lat];
6748
+ }
6749
+ function getZoomLevelFromResolution(tileSize, resolution) {
6750
+ return Math.round(Math.log2(EARTH_CIRCUMFERENCE / (resolution * tileSize)));
6751
+ }
6752
+ function calculateZoomRange(tileSize, resolution, imgCount) {
6753
+ const maxZoom = getZoomLevelFromResolution(tileSize, resolution);
6754
+ const minZoom = maxZoom - (imgCount - 1);
6755
+ return [minZoom, maxZoom];
6756
+ }
6757
+ function calculateBoundsAsLatLon(bbox) {
6758
+ const minX = Math.min(bbox[0], bbox[2]);
6759
+ const maxX = Math.max(bbox[0], bbox[2]);
6760
+ const minY = Math.min(bbox[1], bbox[3]);
6761
+ const maxY = Math.max(bbox[1], bbox[3]);
6762
+ const minXYDeg = getLatLon([minX, minY]);
6763
+ const maxXYDeg = getLatLon([maxX, maxY]);
6764
+ return [minXYDeg[0], minXYDeg[1], maxXYDeg[0], maxXYDeg[1]];
6765
+ }
6766
+
6767
+ /* eslint-disable no-console */
6768
+ /**
6769
+ * Reads TIFF tags to determine the numeric data type (e.g. "UInt8", "Int16", "Float32").
6770
+ * The implementation mirrors the logic previously present in CogTiles.
6771
+ */
6772
+ async function getDataTypeFromTags(fileDirectory) {
6773
+ const hasSampleFormat = fileDirectory.hasTag('SampleFormat');
6774
+ const hasBitsPerSample = fileDirectory.hasTag('BitsPerSample');
6775
+ if (!hasSampleFormat || !hasBitsPerSample) {
6776
+ console.warn("Missing SampleFormat or BitsPerSample tags, defaulting to UInt8");
6777
+ return 'UInt8';
6778
+ }
6779
+ // In GeoTIFF, BitsPerSample (tag 258) and SampleFormat (tag 339) provide the type info.
6780
+ // They can be either a single number or an array if there are multiple samples.
6781
+ const sampleFormat = fileDirectory.getValue('SampleFormat'); // Tag 339
6782
+ const bitsPerSample = fileDirectory.getValue('BitsPerSample'); // Tag 258
6783
+ // If multiple bands exist, assume all bands share the same type.
6784
+ const format = (sampleFormat && typeof sampleFormat.length === 'number' && sampleFormat.length > 0)
6785
+ ? sampleFormat[0]
6786
+ : sampleFormat;
6787
+ const bits = (bitsPerSample && typeof bitsPerSample.length === 'number' && bitsPerSample.length > 0)
6788
+ ? bitsPerSample[0]
6789
+ : bitsPerSample;
6790
+ let typePrefix;
6791
+ // 1 = Unsigned Integer, 2 = Signed Integer, 3 = Floating Point
6792
+ if (format === 1) {
6793
+ typePrefix = 'UInt';
6794
+ }
6795
+ else if (format === 2) {
6796
+ typePrefix = 'Int';
6797
+ }
6798
+ else if (format === 3) {
6799
+ typePrefix = 'Float';
6800
+ }
6801
+ else {
6802
+ typePrefix = 'Unknown';
6803
+ }
6804
+ return `${typePrefix}${bits}`;
6805
+ }
6806
+ /**
6807
+ * Extracts the noData value from a GeoTIFF.js image.
6808
+ * Returns the numeric value, NaN, or undefined if not present/parsable.
6809
+ */
6810
+ function getNoDataValue(image) {
6811
+ const noDataRaw = image.getGDALNoData();
6812
+ if (noDataRaw === undefined || noDataRaw === null) {
6813
+ console.warn('No noData value defined — raster might be rendered incorrectly.');
6814
+ return undefined;
6815
+ }
6816
+ const cleaned = String(noDataRaw).replace(/\0/g, '').trim();
6817
+ if (cleaned === '') {
6818
+ console.warn('noData value is an empty string after cleanup.');
6819
+ return undefined;
6820
+ }
6821
+ const parsed = Number(cleaned);
6822
+ // Allow NaN if explicitly declared
6823
+ if (cleaned.toLowerCase() === 'nan') {
6824
+ return NaN;
6825
+ }
6826
+ // If not declared as "nan" and still parsed to NaN, it's an error
6827
+ if (Number.isNaN(parsed)) {
6828
+ console.warn(`Failed to parse numeric noData value: '${cleaned}'`);
6829
+ return undefined;
6830
+ }
6831
+ return parsed;
6832
+ }
6833
+ /* eslint-enable no-console */
6834
+
6835
+ /**
6836
+ * LOD (level-of-detail) helpers extracted from CogTiles.
6837
+ */
6838
+ function getErrorMultiplierForZoom(z, minZ, maxZ) {
6839
+ if (z >= maxZ)
6840
+ return 0.5;
6841
+ if (z <= minZ)
6842
+ return 3.0;
6843
+ // Linear interpolation: 3.0 - ((z - minZ) / (maxZ - minZ)) * 2.5
6844
+ return 3.0 - ((z - minZ) / (maxZ - minZ)) * 2.5;
6845
+ }
6846
+ function calculateDynamicMeshMaxError(z, resolution, minZ, maxZ) {
6847
+ const multiplier = getErrorMultiplierForZoom(z, minZ, maxZ);
6848
+ const errorValue = resolution * multiplier;
6849
+ return Math.round(Math.max(0.5, Math.min(100, errorValue)));
6850
+ }
6851
+ async function buildCogZoomResolutionLookup(cog) {
6852
+ const imageCount = await cog.getImageCount();
6853
+ const baseImage = await cog.getImage(0);
6854
+ const baseResolution = baseImage.getResolution()[0];
6855
+ const baseWidth = baseImage.getWidth();
6856
+ const zoomLookup = [];
6857
+ const resolutionLookup = [];
6858
+ for (let idx = 0; idx < imageCount; idx++) {
6859
+ const image = await cog.getImage(idx);
6860
+ const width = image.getWidth();
6861
+ const scaleFactor = baseWidth / width;
6862
+ const estimatedResolution = baseResolution * scaleFactor;
6863
+ const zoomLevel = Math.round(Math.log2(webMercatorRes0 / estimatedResolution));
6864
+ zoomLookup[idx] = zoomLevel;
6865
+ resolutionLookup[idx] = estimatedResolution;
6866
+ }
6867
+ return [zoomLookup, resolutionLookup];
6868
+ }
6869
+ function getImageIndexForZoomLevel(zoom, cogZoomLookup) {
6870
+ const minZoom = cogZoomLookup[cogZoomLookup.length - 1];
6871
+ const maxZoom = cogZoomLookup[0];
6872
+ if (zoom > maxZoom)
6873
+ return 0;
6874
+ if (zoom < minZoom)
6875
+ return cogZoomLookup.length - 1;
6876
+ const exactMatchIndex = cogZoomLookup.indexOf(zoom);
6877
+ if (exactMatchIndex !== -1)
6878
+ return exactMatchIndex;
6879
+ let closestIndex = 0;
6880
+ let minDistance = Math.abs(cogZoomLookup[0] - zoom);
6881
+ for (let i = 1; i < cogZoomLookup.length; i += 1) {
6882
+ const distance = Math.abs(cogZoomLookup[i] - zoom);
6883
+ if (distance < minDistance) {
6884
+ minDistance = distance;
6885
+ closestIndex = i;
6886
+ }
6887
+ }
6888
+ return closestIndex;
6889
+ }
6890
+
6891
+ class TileCacheManager {
6892
+ tileResultCache = new Map();
6893
+ tileResultCacheMaxSize;
6894
+ rasterCache = new Map();
6895
+ rasterCacheMaxSize;
6896
+ reliefMaskCache = new Map();
6897
+ reliefMaskCacheMaxSize;
6898
+ imageCache = new Map();
6899
+ constructor(opts) {
6900
+ this.tileResultCacheMaxSize = opts?.tileResultCacheMaxSize ?? 32;
6901
+ this.rasterCacheMaxSize = opts?.rasterCacheMaxSize ?? 64;
6902
+ this.reliefMaskCacheMaxSize = opts?.reliefMaskCacheMaxSize ?? 64;
6903
+ }
6904
+ getTileResultCacheKey(x, y, z, meshMaxError, skipTexture) {
6905
+ return `${z}/${x}/${y}/${meshMaxError}/${skipTexture ? '1' : '0'}`;
6906
+ }
6907
+ getTileCacheKey(x, y, z) {
6908
+ return `${z}/${x}/${y}`;
6909
+ }
6910
+ // TileResult cache methods
6911
+ getTileResult(key) {
6912
+ const entry = this.tileResultCache.get(key);
6913
+ if (!entry)
6914
+ return undefined;
6915
+ // LRU touch
6916
+ this.tileResultCache.delete(key);
6917
+ this.tileResultCache.set(key, entry);
6918
+ return entry;
6919
+ }
6920
+ setTileResult(key, entry) {
6921
+ this.tileResultCache.set(key, entry);
6922
+ if (this.tileResultCache.size > this.tileResultCacheMaxSize) {
6923
+ const oldestKey = this.tileResultCache.keys().next().value;
6924
+ if (oldestKey) {
6925
+ const evicted = this.tileResultCache.get(oldestKey);
6926
+ if (evicted && !evicted.settled)
6927
+ evicted.controller.abort();
6928
+ this.tileResultCache.delete(oldestKey);
6929
+ }
6930
+ }
6931
+ }
6932
+ deleteTileResult(key) {
6933
+ return this.tileResultCache.delete(key);
6934
+ }
6935
+ clearTileResultCache() {
6936
+ for (const entry of this.tileResultCache.values()) {
6937
+ if (!entry.settled)
6938
+ entry.controller.abort();
6939
+ }
6940
+ this.tileResultCache.clear();
6941
+ }
6942
+ // Raster cache methods
6943
+ getRaster(key) {
6944
+ const p = this.rasterCache.get(key);
6945
+ if (!p)
6946
+ return undefined;
6947
+ // LRU touch
6948
+ this.rasterCache.delete(key);
6949
+ this.rasterCache.set(key, p);
6950
+ return p;
6951
+ }
6952
+ setRaster(key, p) {
6953
+ this.rasterCache.set(key, p);
6954
+ p.catch(() => this.rasterCache.delete(key));
6955
+ if (this.rasterCache.size > this.rasterCacheMaxSize) {
6956
+ const oldestKey = this.rasterCache.keys().next().value;
6957
+ if (oldestKey)
6958
+ this.rasterCache.delete(oldestKey);
6959
+ }
6960
+ }
6961
+ deleteRaster(key) {
6962
+ return this.rasterCache.delete(key);
6963
+ }
6964
+ clearRasterCache() {
6965
+ this.rasterCache.clear();
6966
+ }
6967
+ // Relief mask cache methods
6968
+ getReliefMask(key) {
6969
+ const p = this.reliefMaskCache.get(key);
6970
+ if (!p)
6971
+ return undefined;
6972
+ // LRU touch
6973
+ this.reliefMaskCache.delete(key);
6974
+ this.reliefMaskCache.set(key, p);
6975
+ return p;
6976
+ }
6977
+ setReliefMask(key, p) {
6978
+ this.reliefMaskCache.set(key, p);
6979
+ p.catch(() => this.reliefMaskCache.delete(key));
6980
+ if (this.reliefMaskCache.size > this.reliefMaskCacheMaxSize) {
6981
+ const oldestKey = this.reliefMaskCache.keys().next().value;
6982
+ if (oldestKey)
6983
+ this.reliefMaskCache.delete(oldestKey);
6984
+ }
6985
+ }
6986
+ deleteReliefMask(key) {
6987
+ return this.reliefMaskCache.delete(key);
6988
+ }
6989
+ clearReliefMaskCache() {
6990
+ this.reliefMaskCache.clear();
6991
+ }
6992
+ // Image cache methods
6993
+ getImage(index) {
6994
+ return this.imageCache.get(index);
6995
+ }
6996
+ setImage(index, p) {
6997
+ this.imageCache.set(index, p);
6998
+ }
6999
+ deleteImage(index) {
7000
+ return this.imageCache.delete(index);
7001
+ }
7002
+ clearImageCache() {
7003
+ this.imageCache.clear();
7004
+ }
7005
+ // Clear everything
7006
+ clearAll() {
7007
+ this.clearTileResultCache();
7008
+ this.clearRasterCache();
7009
+ this.clearReliefMaskCache();
7010
+ this.clearImageCache();
7011
+ }
7012
+ }
7013
+
7014
+ class TileReader {
7015
+ options;
7016
+ tileSize;
7017
+ constructor(params) {
7018
+ this.options = params.options;
7019
+ this.tileSize = params.tileSize;
7020
+ }
7021
+ createEmptyTile(size) {
7022
+ const s = size || this.tileSize;
7023
+ const channels = this.options.numOfChannels || 1;
7024
+ const totalSize = s * s * channels;
7025
+ const tileData = new Float32Array(totalSize);
7026
+ if (this.options.noDataValue !== undefined) {
7027
+ tileData.fill(this.options.noDataValue);
7028
+ }
7029
+ return tileData;
7030
+ }
7031
+ createTileBuffer(dataType, tileSize, multiplier = 1) {
7032
+ const length = tileSize * tileSize * multiplier;
7033
+ switch (dataType) {
7034
+ case 'UInt8':
7035
+ return new Uint8Array(length);
7036
+ case 'Int8':
7037
+ return new Int8Array(length);
7038
+ case 'UInt16':
7039
+ return new Uint16Array(length);
7040
+ case 'Int16':
7041
+ return new Int16Array(length);
7042
+ case 'UInt32':
7043
+ return new Uint32Array(length);
7044
+ case 'Int32':
7045
+ return new Int32Array(length);
7046
+ case 'Float32':
7047
+ return new Float32Array(length);
7048
+ case 'Float64':
7049
+ return new Float64Array(length);
7050
+ default:
7051
+ // eslint-disable-next-line no-console
7052
+ console.warn(`Unsupported data type: ${dataType}, defaulting to Float32`);
7053
+ return new Float32Array(length);
7054
+ }
7055
+ }
7056
+ }
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();
6739
7064
  const CogTilesGeoImageOptionsDefaults = {
6740
7065
  blurredTexture: true,
7066
+ // When true, log per-tile per-band min/max values for debugging (disabled by default)
7067
+ debugTileStats: false,
6741
7068
  };
6742
7069
  class CogTiles {
6743
7070
  cog;
@@ -6748,6 +7075,7 @@ class CogTiles {
6748
7075
  zoomRange = [0, 0];
6749
7076
  tileSize = 256;
6750
7077
  bounds = [0, 0, 0, 0];
7078
+ bandDescriptions = [];
6751
7079
  geo = new GeoImage();
6752
7080
  options;
6753
7081
  // TileResult cache — keyed by z/x/y/meshMaxError.
@@ -6757,34 +7085,8 @@ class CogTiles {
6757
7085
  // and individual tile cancellations (e.g. from panning) do not poison other callers.
6758
7086
  // Once the promise settles (resolved or rejected), controller/callerCount are irrelevant;
6759
7087
  // future cache hits just await the already-resolved promise directly.
6760
- tileResultCache = new Map();
6761
- tileResultCacheMaxSize = 32;
6762
- getTileResultCacheKey(x, y, z, meshMaxError, skipTexture) {
6763
- return `${z}/${x}/${y}/${meshMaxError}/${skipTexture ? '1' : '0'}`;
6764
- }
6765
- /** Clears the TileResult cache. Call when the COG URL or meshMaxError changes. */
6766
- clearTileResultCache() {
6767
- // Abort any in-flight pipelines so their network requests are cancelled
6768
- for (const entry of this.tileResultCache.values()) {
6769
- if (!entry.settled)
6770
- entry.controller.abort();
6771
- }
6772
- this.tileResultCache.clear();
6773
- }
6774
- // Raw raster cache for ordinary bitmap layers — saves network fetch + decompression on revisit.
6775
- // BitmapGenerator is cheap to re-run from cached raster; no need to hold ImageBitmaps in memory.
6776
- rasterCache = new Map();
6777
- rasterCacheMaxSize = 64;
6778
- // Relief mask cache for bitmap + glaze layers — saves network fetch + kernel convolution on revisit.
6779
- // Stores the Float32Array output of composeSwissRelief; BitmapGenerator re-runs from it cheaply.
6780
- reliefMaskCache = new Map();
6781
- reliefMaskCacheMaxSize = 64;
6782
- getTileCacheKey(x, y, z) {
6783
- return `${z}/${x}/${y}`;
6784
- }
6785
- // Cache GeoTIFFImage Promises by index to prevent redundant HTTP requests from geotiff 3.0.4+ eager loading
6786
- // Stores Promises (not resolved values) so concurrent requests share the same getImage() call
6787
- imageCache = new Map();
7088
+ cache = new TileCacheManager();
7089
+ tileReader;
6788
7090
  // Store initialization promise to prevent concurrent duplicate initializations
6789
7091
  initializePromise;
6790
7092
  // Track the last successfully initialized URL to detect URL changes
@@ -6801,10 +7103,15 @@ class CogTiles {
6801
7103
  // Fully reset COG-derived state when the URL changes so the instance can be
6802
7104
  // safely reinitialized against a different source.
6803
7105
  if (this.lastInitializedUrl !== undefined && this.lastInitializedUrl !== url) {
6804
- this.clearTileResultCache();
6805
- this.rasterCache.clear();
6806
- this.reliefMaskCache.clear();
6807
- this.imageCache.clear();
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));
6808
7115
  this.cog = undefined;
6809
7116
  this.cogOrigin = [0, 0];
6810
7117
  this.cogZoomLookup = [];
@@ -6813,6 +7120,7 @@ class CogTiles {
6813
7120
  this.tileSize = 256;
6814
7121
  this.zoomRange = [0, 0];
6815
7122
  this.bounds = [0, 0, 0, 0];
7123
+ this.bandDescriptions = [];
6816
7124
  this.initializePromise = undefined;
6817
7125
  this.lastInitializedUrl = undefined;
6818
7126
  }
@@ -6830,15 +7138,40 @@ class CogTiles {
6830
7138
  const blockSize = this.options.blockSize ?? 65536;
6831
7139
  this.cog = await fromUrl(url, { blockSize });
6832
7140
  const imagePromise = this.cog.getImage();
6833
- this.imageCache.set(0, imagePromise); // Cache base image (index 0) to avoid re-fetching during getTileFromImage
7141
+ this.cache.setImage(0, imagePromise); // Cache base image (index 0) to avoid re-fetching during getTileFromImage
6834
7142
  const image = await imagePromise;
6835
7143
  const fileDirectory = image.fileDirectory;
6836
7144
  this.cogOrigin = image.getOrigin();
6837
- this.options.noDataValue ??= await this.getNoDataValue(image);
6838
- this.options.format ??= await this.getDataTypeFromTags(fileDirectory);
7145
+ this.options.noDataValue ??= getNoDataValue(image);
7146
+ this.options.format ??= await getDataTypeFromTags(fileDirectory);
6839
7147
  this.options.numOfChannels = fileDirectory.getValue('SamplesPerPixel');
6840
7148
  this.options.planarConfig = fileDirectory.getValue('PlanarConfiguration');
6841
- [this.cogZoomLookup, this.cogResolutionLookup] = await this.buildCogZoomResolutionLookup(this.cog);
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;
7174
+ [this.cogZoomLookup, this.cogResolutionLookup] = await buildCogZoomResolutionLookup(this.cog);
6842
7175
  // Only compute quantized meshMaxError lookup for terrain COGs
6843
7176
  if (this.options.type === 'terrain') {
6844
7177
  this.computeMeshMaxErrorLookup();
@@ -6849,8 +7182,13 @@ class CogTiles {
6849
7182
  throw new Error('GeoTIFF Error: The provided image is not tiled. '
6850
7183
  + 'Please use "rio cogeo create --web-optimized" to fix this.');
6851
7184
  }
6852
- this.zoomRange = this.calculateZoomRange(this.tileSize, image.getResolution()[0], await this.cog.getImageCount());
6853
- this.bounds = this.calculateBoundsAsLatLon(image.getBoundingBox());
7185
+ this.zoomRange = calculateZoomRange(this.tileSize, image.getResolution()[0], await this.cog.getImageCount());
7186
+ this.bounds = calculateBoundsAsLatLon(image.getBoundingBox());
7187
+ // Initialize TileReader with buffer utilities
7188
+ this.tileReader = new TileReader({
7189
+ options: this.options,
7190
+ tileSize: this.tileSize,
7191
+ });
6854
7192
  // Mark initialization complete for this URL (used to detect URL changes)
6855
7193
  this.lastInitializedUrl = url;
6856
7194
  }
@@ -6867,107 +7205,30 @@ class CogTiles {
6867
7205
  getZoomRange() {
6868
7206
  return this.zoomRange;
6869
7207
  }
6870
- calculateZoomRange(tileSize, resolution, imgCount) {
6871
- const maxZoom = this.getZoomLevelFromResolution(tileSize, resolution);
6872
- const minZoom = maxZoom - (imgCount - 1);
6873
- return [minZoom, maxZoom];
6874
- }
6875
- calculateBoundsAsLatLon(bbox) {
6876
- const minX = Math.min(bbox[0], bbox[2]);
6877
- const maxX = Math.max(bbox[0], bbox[2]);
6878
- const minY = Math.min(bbox[1], bbox[3]);
6879
- const maxY = Math.max(bbox[1], bbox[3]);
6880
- const minXYDeg = this.getLatLon([minX, minY]);
6881
- const maxXYDeg = this.getLatLon([maxX, maxY]);
6882
- return [minXYDeg[0], minXYDeg[1], maxXYDeg[0], maxXYDeg[1]];
6883
- }
6884
- getZoomLevelFromResolution(tileSize, resolution) {
6885
- return Math.round(Math.log2(EARTH_CIRCUMFERENCE / (resolution * tileSize)));
6886
- }
6887
7208
  getBoundsAsLatLon() {
6888
7209
  return this.bounds;
6889
7210
  }
6890
- getLatLon(input) {
6891
- const x = input[0];
6892
- const y = input[1];
6893
- const lon = (x / EARTH_HALF_CIRCUMFERENCE) * 180;
6894
- let lat = (y / EARTH_HALF_CIRCUMFERENCE) * 180;
6895
- lat = (180 / Math.PI) * (2 * Math.atan(Math.exp((lat * Math.PI) / 180)) - Math.PI / 2);
6896
- return [lon, lat];
6897
- }
6898
7211
  /**
6899
- * Calculates the error multiplier based on zoom level and COG zoom range.
6900
- * - z >= maxZ: multiplier = 0.5 (fine meshes at high zoom, maximum precision)
6901
- * - z <= minZ: multiplier = 3.0 (coarse meshes at low zoom, maximum performance)
6902
- * - Otherwise: linear interpolation between 3.0 and 0.5
7212
+ * Gets the number of channels/bands in the COG.
7213
+ * Returns the value from COG metadata, or 1 if not initialized.
6903
7214
  */
6904
- getErrorMultiplierForZoom(z, minZ, maxZ) {
6905
- if (z >= maxZ)
6906
- return 0.5;
6907
- if (z <= minZ)
6908
- return 3.0;
6909
- // Linear interpolation: 3.0 - ((z - minZ) / (maxZ - minZ)) * 2.5
6910
- return 3.0 - ((z - minZ) / (maxZ - minZ)) * 2.5;
7215
+ getNumChannels() {
7216
+ return this.options.numOfChannels || 1;
6911
7217
  }
6912
7218
  /**
6913
- * Calculates dynamic meshMaxError for a given zoom level and resolution.
6914
- * Formula: resolution * errorMultiplier, where multiplier scales from 3.0 (low zoom) to 0.5 (high zoom).
6915
- * Results are clamped to 0.5–100 meters to prevent pathological tessellation:
6916
- * - Min 0.5m ensures simplification always happens (no rounding to 0 with sub-meter pixels)
6917
- * - Max 100m prevents excessive simplification at low zoom with low-resolution COGs
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.
6918
7221
  */
6919
- calculateDynamicMeshMaxError(z, resolution, minZ, maxZ) {
6920
- const multiplier = this.getErrorMultiplierForZoom(z, minZ, maxZ);
6921
- const errorValue = resolution * multiplier;
6922
- return Math.max(0.5, Math.min(100, errorValue));
7222
+ getBandDescriptions() {
7223
+ return this.bandDescriptions;
6923
7224
  }
6924
7225
  /**
6925
- * Gets the auto meshMaxError for a given overview index.
6926
- * Returns undefined if auto lookup has not been computed.
6927
- */
7226
+ * Gets the auto meshMaxError for a given overview index.
7227
+ * Returns undefined if auto lookup has not been computed.
7228
+ */
6928
7229
  getMeshMaxErrorForImageIndex(imageIndex) {
6929
7230
  return this.cogMeshMaxErrorLookup[imageIndex];
6930
7231
  }
6931
- /**
6932
- * Builds lookup tables for zoom levels and estimated resolutions from a Cloud Optimized GeoTIFF (COG) object.
6933
- *
6934
- * It is assumed that inn web mapping, COG data is visualized in the Web Mercator coordinate system.
6935
- * At zoom level 0, the Web Mercator resolution is defined by the constant `webMercatorRes0`
6936
- * (e.g., 156543.03125 m/pixel). At each subsequent zoom level, this resolution is halved.
6937
- *
6938
- * This function calculates, for each image (overview) in the COG, its estimated resolution and
6939
- * corresponding zoom level based on the base image's resolution and width.
6940
- *
6941
- * @param {object} cog - A Cloud Optimized GeoTIFF object loaded via geotiff.js.
6942
- * @returns {Promise<[number[], number[]]>} A promise resolving to a tuple of two arrays:
6943
- * - The first array (`zoomLookup`) maps each image index to its computed zoom level.
6944
- * - The second array (`resolutionLookup`) maps each image index to its estimated resolution (m/pixel).
6945
- */
6946
- async buildCogZoomResolutionLookup(cog) {
6947
- // Retrieve the total number of images (overviews) in the COG.
6948
- const imageCount = await cog.getImageCount();
6949
- // Use the first image as the base reference.
6950
- const baseImage = await cog.getImage(0);
6951
- const baseResolution = baseImage.getResolution()[0]; // Resolution (m/pixel) of the base image.
6952
- const baseWidth = baseImage.getWidth();
6953
- // Initialize arrays to store the zoom level and resolution for each image.
6954
- const zoomLookup = [];
6955
- const resolutionLookup = [];
6956
- // Iterate over each image (overview) in the COG.
6957
- for (let idx = 0; idx < imageCount; idx++) {
6958
- const image = await cog.getImage(idx);
6959
- const width = image.getWidth();
6960
- // Calculate the scale factor relative to the base image.
6961
- const scaleFactor = baseWidth / width;
6962
- const estimatedResolution = baseResolution * scaleFactor;
6963
- // Calculate the zoom level using the Web Mercator resolution standard:
6964
- // webMercatorRes0 is the resolution at zoom level 0; each zoom level halves the resolution.
6965
- const zoomLevel = Math.round(Math.log2(webMercatorRes0 / estimatedResolution));
6966
- zoomLookup[idx] = zoomLevel;
6967
- resolutionLookup[idx] = estimatedResolution;
6968
- }
6969
- return [zoomLookup, resolutionLookup];
6970
- }
6971
7232
  /**
6972
7233
  * Computes dynamic meshMaxError values for each overview based on COG resolution and zoom level.
6973
7234
  * Called only for terrain COGs after buildCogZoomResolutionLookup() completes.
@@ -6979,7 +7240,7 @@ class CogTiles {
6979
7240
  const maxZ = this.cogZoomLookup[0];
6980
7241
  this.cogMeshMaxErrorLookup = this.cogResolutionLookup.map((resolution, idx) => {
6981
7242
  const zoom = this.cogZoomLookup[idx];
6982
- return this.calculateDynamicMeshMaxError(zoom, resolution, minZ, maxZ);
7243
+ return calculateDynamicMeshMaxError(zoom, resolution, minZ, maxZ);
6983
7244
  });
6984
7245
  }
6985
7246
  /**
@@ -6994,29 +7255,7 @@ class CogTiles {
6994
7255
  * @returns {number} The index of the image in the COG that best matches the specified zoom level.
6995
7256
  */
6996
7257
  getImageIndexForZoomLevel(zoom) {
6997
- // Retrieve the minimum and maximum zoom levels from the lookup table.
6998
- const minZoom = this.cogZoomLookup[this.cogZoomLookup.length - 1];
6999
- const maxZoom = this.cogZoomLookup[0];
7000
- if (zoom > maxZoom)
7001
- return 0;
7002
- if (zoom < minZoom)
7003
- return this.cogZoomLookup.length - 1;
7004
- // For zoom levels within the available range, find the exact or closest matching index.
7005
- const exactMatchIndex = this.cogZoomLookup.indexOf(zoom);
7006
- if (exactMatchIndex !== -1) {
7007
- return exactMatchIndex;
7008
- }
7009
- // No exact match: find the closest zoom level
7010
- let closestIndex = 0;
7011
- let minDistance = Math.abs(this.cogZoomLookup[0] - zoom);
7012
- for (let i = 1; i < this.cogZoomLookup.length; i += 1) {
7013
- const distance = Math.abs(this.cogZoomLookup[i] - zoom);
7014
- if (distance < minDistance) {
7015
- minDistance = distance;
7016
- closestIndex = i;
7017
- }
7018
- }
7019
- return closestIndex;
7258
+ return getImageIndexForZoomLevel(zoom, this.cogZoomLookup);
7020
7259
  }
7021
7260
  async getTileFromImage(tileX, tileY, zoom, fetchSize, signal) {
7022
7261
  // Create a fresh local AbortController for this specific fetch.
@@ -7033,10 +7272,10 @@ class CogTiles {
7033
7272
  try {
7034
7273
  const imageIndex = this.getImageIndexForZoomLevel(zoom);
7035
7274
  // Cache Promises to share in-flight requests across concurrent tiles at the same overview
7036
- let imagePromise = this.imageCache.get(imageIndex);
7275
+ let imagePromise = this.cache.getImage(imageIndex);
7037
7276
  if (!imagePromise) {
7038
7277
  imagePromise = this.cog.getImage(imageIndex);
7039
- this.imageCache.set(imageIndex, imagePromise);
7278
+ this.cache.setImage(imageIndex, imagePromise);
7040
7279
  }
7041
7280
  const targetImage = await imagePromise;
7042
7281
  // --- STEP 1: CALCULATE BOUNDS IN METERS ---
@@ -7079,7 +7318,7 @@ class CogTiles {
7079
7318
  const readHeight = validReadMaxY - validReadY;
7080
7319
  // CHECK: If no overlap, return empty
7081
7320
  if (readWidth <= 0 || readHeight <= 0) {
7082
- return [this.createEmptyTile(FETCH_SIZE)];
7321
+ return [this.tileReader.createEmptyTile(FETCH_SIZE)];
7083
7322
  }
7084
7323
  // 8. Calculate Offsets (Padding)
7085
7324
  // "missingLeft" is how many blank pixels we need to insert before the image data starts.
@@ -7095,7 +7334,7 @@ class CogTiles {
7095
7334
  if (missingLeft > 0 || missingTop > 0 || readWidth < FETCH_SIZE || readHeight < FETCH_SIZE) {
7096
7335
  const numChannels = this.options.numOfChannels || 1;
7097
7336
  // Initialize with a TypedArray of the full target size and correct data type
7098
- const validImageData = this.createTileBuffer(this.options.format || 'Float32', FETCH_SIZE, numChannels);
7337
+ const validImageData = this.tileReader.createTileBuffer(this.options.format || 'Float32', FETCH_SIZE, numChannels);
7099
7338
  if (this.options.noDataValue !== undefined) {
7100
7339
  validImageData.fill(this.options.noDataValue);
7101
7340
  }
@@ -7104,7 +7343,7 @@ class CogTiles {
7104
7343
  // Place the valid pixel data into the tile buffer.
7105
7344
  for (let band = 0; band < validRasterData.length; band += 1) {
7106
7345
  // We must reset the buffer for each band, otherwise data from previous band persists in padding areas
7107
- const tileBuffer = this.createTileBuffer(this.options.format || 'Float32', FETCH_SIZE);
7346
+ const tileBuffer = this.tileReader.createTileBuffer(this.options.format || 'Float32', FETCH_SIZE);
7108
7347
  if (this.options.noDataValue !== undefined) {
7109
7348
  tileBuffer.fill(this.options.noDataValue);
7110
7349
  }
@@ -7167,20 +7406,6 @@ class CogTiles {
7167
7406
  throw error;
7168
7407
  }
7169
7408
  }
7170
- /**
7171
- * Creates a blank tile buffer filled with the "No Data" value.
7172
- * @param size The width/height of the square tile (e.g., 256 or 257)
7173
- */
7174
- createEmptyTile(size) {
7175
- const s = size || this.tileSize; // Defaults to 256
7176
- const channels = this.options.numOfChannels || 1;
7177
- const totalSize = s * s * channels;
7178
- const tileData = new Float32Array(totalSize);
7179
- if (this.options.noDataValue !== undefined) {
7180
- tileData.fill(this.options.noDataValue);
7181
- }
7182
- return tileData;
7183
- }
7184
7409
  async getTile(x, y, z, bounds, meshMaxError, signal, skipTexture) {
7185
7410
  // cellSizeMeters is derived purely from tile coordinates — compute once for all paths
7186
7411
  const latRad = Math.atan(Math.sinh(Math.PI * (1 - 2 * (y + 0.5) / Math.pow(2, z))));
@@ -7198,258 +7423,271 @@ class CogTiles {
7198
7423
  else {
7199
7424
  resolvedMeshMaxError = meshMaxError ?? 4.0;
7200
7425
  }
7201
- // ── PATH A: Terrain ──────────────────────────────────────────────────────────
7202
- // Full TileResult (mesh + raw + texture) cached with ref-counted abort so that
7203
- // panning cancels in-flight fetches only when ALL callers have cancelled.
7204
7426
  if (isTerrain) {
7205
- const skipTextureFlag = skipTexture ?? this.options.skipTexture ?? false;
7206
- const cacheKey = this.getTileResultCacheKey(x, y, z, resolvedMeshMaxError, skipTextureFlag);
7207
- const existing = this.tileResultCache.get(cacheKey);
7208
- if (existing) {
7209
- // LRU touch — move the key to the end of the Map to mark as recently used
7210
- this.tileResultCache.delete(cacheKey);
7211
- this.tileResultCache.set(cacheKey, existing);
7212
- }
7213
- if (existing) {
7214
- if (existing.settled) {
7215
- if (signal?.aborted)
7216
- return null;
7217
- return existing.promise;
7427
+ return this.getTerrainTile(x, y, z, bounds, resolvedMeshMaxError, cellSizeMeters, signal, skipTexture);
7428
+ }
7429
+ if (isGlaze) {
7430
+ return this.getGlazeTile(x, y, z, bounds, cellSizeMeters, meshMaxError, signal);
7431
+ }
7432
+ return this.getBitmapTile(x, y, z, bounds, cellSizeMeters, meshMaxError, signal);
7433
+ }
7434
+ async getTerrainTile(x, y, z, bounds, resolvedMeshMaxError, cellSizeMeters, signal, skipTexture) {
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;
7218
7451
  }
7219
- existing.callerCount += 1;
7220
- if (signal && !signal.aborted) {
7221
- signal.addEventListener('abort', () => {
7222
- existing.callerCount -= 1;
7223
- if (existing.callerCount <= 0 && !existing.settled) {
7224
- existing.controller.abort();
7225
- }
7226
- }, { once: true });
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];
7227
7476
  }
7228
- const result = await existing.promise;
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
+ }
7484
+ const cacheKey = this.cache.getTileResultCacheKey(x, y, z, resolvedMeshMaxError, skipTextureFlag);
7485
+ const existing = this.cache.getTileResult(cacheKey);
7486
+ if (existing) {
7487
+ if (existing.settled) {
7229
7488
  if (signal?.aborted)
7230
7489
  return null;
7231
- return result;
7490
+ return existing.promise;
7232
7491
  }
7233
- const controller = new AbortController();
7234
- const pipeline = (async () => {
7235
- const isKernel = this.options.useSlope || this.options.useHillshade || this.options.useSwissRelief;
7236
- const requiredSize = this.tileSize + (isKernel ? 2 : 1);
7237
- const tileData = await this.getTileFromImage(x, y, z, requiredSize, controller.signal);
7238
- // === Step F: detect all-noData tiles before tessellation ===
7239
- const raster = tileData[0];
7240
- const noData = this.options.noDataValue;
7241
- if (noData !== undefined && raster) {
7242
- const numChannels = this.options.numOfChannels || 1;
7243
- let useChannelIndex = this.options.useChannelIndex ?? (this.options.useChannel ? (this.options.useChannel - 1) : 0);
7244
- if (useChannelIndex == null)
7245
- useChannelIndex = 0;
7246
- const checkStrategy = this.options.noDataCheck ?? 'full';
7247
- const width = requiredSize;
7248
- const height = requiredSize;
7249
- const isNoValue = (v) => (Number.isNaN(noData) ? Number.isNaN(v) : v === noData);
7250
- let allNoData = true;
7251
- if (checkStrategy === 'full') {
7252
- // Full linear scan (safe)
7253
- if (numChannels > 1) {
7254
- for (let i = useChannelIndex; i < raster.length; i += numChannels) {
7255
- const v = raster[i];
7256
- if (!isNoValue(v)) {
7257
- allNoData = false;
7258
- break;
7259
- }
7492
+ existing.callerCount += 1;
7493
+ if (signal && !signal.aborted) {
7494
+ signal.addEventListener('abort', () => {
7495
+ existing.callerCount -= 1;
7496
+ if (existing.callerCount <= 0 && !existing.settled) {
7497
+ existing.controller.abort();
7498
+ }
7499
+ }, { once: true });
7500
+ }
7501
+ const result = await existing.promise;
7502
+ if (signal?.aborted)
7503
+ return null;
7504
+ return result;
7505
+ }
7506
+ const controller = new AbortController();
7507
+ const pipeline = (async () => {
7508
+ const isKernel = this.options.useSlope || this.options.useHillshade || this.options.useSwissRelief;
7509
+ const requiredSize = this.tileSize + (isKernel ? 2 : 1);
7510
+ const tileData = await this.getTileFromImage(x, y, z, requiredSize, controller.signal);
7511
+ // === Step F: detect all-noData tiles before tessellation ===
7512
+ const raster = tileData[0];
7513
+ const noData = this.options.noDataValue;
7514
+ if (noData !== undefined && raster) {
7515
+ const numChannels = this.options.numOfChannels || 1;
7516
+ let useChannelIndex = this.options.useChannelIndex ?? (this.options.useChannel ? (this.options.useChannel - 1) : 0);
7517
+ if (useChannelIndex == null)
7518
+ useChannelIndex = 0;
7519
+ const checkStrategy = this.options.noDataCheck ?? 'full';
7520
+ const width = requiredSize;
7521
+ const height = requiredSize;
7522
+ const isNoValue = (v) => isF32NoData(v, noData);
7523
+ let allNoData = true;
7524
+ if (checkStrategy === 'full') {
7525
+ // Full linear scan (safe)
7526
+ if (numChannels > 1) {
7527
+ for (let i = useChannelIndex; i < raster.length; i += numChannels) {
7528
+ const v = raster[i];
7529
+ if (!isNoValue(v)) {
7530
+ allNoData = false;
7531
+ break;
7260
7532
  }
7261
7533
  }
7262
- else {
7263
- for (let i = 0; i < raster.length; i++) {
7264
- const v = raster[i];
7265
- if (!isNoValue(v)) {
7266
- allNoData = false;
7267
- break;
7268
- }
7534
+ }
7535
+ else {
7536
+ for (let i = 0; i < raster.length; i++) {
7537
+ const v = raster[i];
7538
+ if (!isNoValue(v)) {
7539
+ allNoData = false;
7540
+ break;
7269
7541
  }
7270
7542
  }
7271
7543
  }
7272
- else if (checkStrategy === 'border+center') {
7273
- // Border scan: iterate over top/bottom rows and left/right cols
7274
- const stepX = numChannels;
7275
- // Top row
7544
+ }
7545
+ else if (checkStrategy === 'border+center') {
7546
+ // Border scan: iterate over top/bottom rows and left/right cols
7547
+ const stepX = numChannels;
7548
+ // Top row
7549
+ for (let x = 0; x < width; x++) {
7550
+ const idx = x * stepX + useChannelIndex;
7551
+ const v = raster[idx];
7552
+ if (!isNoValue(v)) {
7553
+ allNoData = false;
7554
+ break;
7555
+ }
7556
+ }
7557
+ // Bottom row
7558
+ if (allNoData) {
7276
7559
  for (let x = 0; x < width; x++) {
7277
- const idx = x * stepX + useChannelIndex;
7560
+ const idx = ((height - 1) * width + x) * stepX + useChannelIndex;
7278
7561
  const v = raster[idx];
7279
7562
  if (!isNoValue(v)) {
7280
7563
  allNoData = false;
7281
7564
  break;
7282
7565
  }
7283
7566
  }
7284
- // Bottom row
7285
- if (allNoData) {
7286
- for (let x = 0; x < width; x++) {
7287
- const idx = ((height - 1) * width + x) * stepX + useChannelIndex;
7288
- const v = raster[idx];
7289
- if (!isNoValue(v)) {
7290
- allNoData = false;
7291
- break;
7292
- }
7293
- }
7294
- }
7295
- // Left/Right cols
7296
- if (allNoData) {
7297
- for (let y = 1; y < height - 1; y++) {
7298
- const leftIdx = (y * width) * stepX + useChannelIndex;
7299
- const rightIdx = (y * width + (width - 1)) * stepX + useChannelIndex;
7300
- const vl = raster[leftIdx];
7301
- const vr = raster[rightIdx];
7302
- if (!isNoValue(vl) || !isNoValue(vr)) {
7303
- allNoData = false;
7304
- break;
7305
- }
7306
- }
7307
- }
7308
- // Center probe + 4 quadrant probes
7309
- if (allNoData) {
7310
- const probes = [
7311
- [Math.floor(width / 2), Math.floor(height / 2)],
7312
- [Math.floor(width / 4), Math.floor(height / 4)],
7313
- [Math.floor((3 * width) / 4), Math.floor(height / 4)],
7314
- [Math.floor(width / 4), Math.floor((3 * height) / 4)],
7315
- [Math.floor((3 * width) / 4), Math.floor((3 * height) / 4)],
7316
- ];
7317
- for (const [px, py] of probes) {
7318
- const idx = (py * width + px) * stepX + useChannelIndex;
7319
- const v = raster[idx];
7320
- if (!isNoValue(v)) {
7321
- allNoData = false;
7322
- break;
7323
- }
7567
+ }
7568
+ // Left/Right cols
7569
+ if (allNoData) {
7570
+ for (let y = 1; y < height - 1; y++) {
7571
+ const leftIdx = (y * width) * stepX + useChannelIndex;
7572
+ const rightIdx = (y * width + (width - 1)) * stepX + useChannelIndex;
7573
+ const vl = raster[leftIdx];
7574
+ const vr = raster[rightIdx];
7575
+ if (!isNoValue(vl) || !isNoValue(vr)) {
7576
+ allNoData = false;
7577
+ break;
7324
7578
  }
7325
7579
  }
7326
7580
  }
7327
- else {
7328
- // Unknown strategy — fallback to full
7329
- for (let i = 0; i < raster.length; i++) {
7330
- const v = raster[i];
7581
+ // Center probe + 4 quadrant probes
7582
+ if (allNoData) {
7583
+ const probes = [
7584
+ [Math.floor(width / 2), Math.floor(height / 2)],
7585
+ [Math.floor(width / 4), Math.floor(height / 4)],
7586
+ [Math.floor((3 * width) / 4), Math.floor(height / 4)],
7587
+ [Math.floor(width / 4), Math.floor((3 * height) / 4)],
7588
+ [Math.floor((3 * width) / 4), Math.floor((3 * height) / 4)],
7589
+ ];
7590
+ for (const [px, py] of probes) {
7591
+ const idx = (py * width + px) * stepX + useChannelIndex;
7592
+ const v = raster[idx];
7331
7593
  if (!isNoValue(v)) {
7332
7594
  allNoData = false;
7333
7595
  break;
7334
7596
  }
7335
7597
  }
7336
7598
  }
7337
- if (allNoData) {
7338
- // Do not cache all-noData result; remove cache entry so future requests re-evaluate if COG/metadata changes.
7339
- this.tileResultCache.delete(cacheKey);
7340
- return null;
7341
- }
7342
7599
  }
7343
- // Create generator options with skipTextureFlag applied (don't mutate shared this.options)
7344
- const generatorOptions = {
7345
- ...this.options,
7346
- skipTexture: skipTextureFlag,
7347
- };
7348
- return this.geo.getMap({
7349
- rasters: [tileData[0]],
7350
- width: requiredSize,
7351
- height: requiredSize,
7352
- bounds: bounds ?? [0, 0, 0, 0],
7353
- cellSizeMeters,
7354
- }, generatorOptions, resolvedMeshMaxError);
7355
- })();
7356
- const entry = {
7357
- promise: pipeline,
7358
- controller,
7359
- callerCount: 1,
7360
- settled: false,
7361
- };
7362
- if (signal && !signal.aborted) {
7363
- signal.addEventListener('abort', () => {
7364
- entry.callerCount -= 1;
7365
- if (entry.callerCount <= 0 && !entry.settled) {
7366
- entry.controller.abort();
7600
+ else {
7601
+ // Unknown strategy — fallback to full
7602
+ for (let i = 0; i < raster.length; i++) {
7603
+ const v = raster[i];
7604
+ if (!isNoValue(v)) {
7605
+ allNoData = false;
7606
+ break;
7607
+ }
7367
7608
  }
7368
- }, { once: true });
7369
- }
7370
- entry.promise = pipeline;
7371
- this.tileResultCache.set(cacheKey, entry);
7372
- if (this.tileResultCache.size > this.tileResultCacheMaxSize) {
7373
- const oldestKey = this.tileResultCache.keys().next().value;
7374
- if (typeof oldestKey === 'string') {
7375
- const evicted = this.tileResultCache.get(oldestKey);
7376
- if (evicted && !evicted.settled)
7377
- evicted.controller.abort();
7378
- this.tileResultCache.delete(oldestKey);
7379
7609
  }
7380
- }
7381
- try {
7382
- const result = await pipeline;
7383
- entry.settled = true;
7384
- if (signal?.aborted)
7610
+ if (allNoData) {
7611
+ // Do not cache all-noData result; remove cache entry so future requests re-evaluate if COG/metadata changes.
7612
+ this.cache.deleteTileResult(cacheKey);
7385
7613
  return null;
7386
- return result;
7387
- }
7388
- catch (error) {
7389
- entry.settled = true;
7390
- this.tileResultCache.delete(cacheKey);
7391
- throw error;
7392
- }
7393
- }
7394
- // ── PATH B: Bitmap + glaze ────────────────────────────────────────────────────
7395
- // Relief mask (output of composeSwissRelief) cached — saves fetch + kernel on revisit.
7396
- // BitmapGenerator re-runs cheaply from the cached Float32Array.
7397
- // Signal is passed so cancelled tiles abort cleanly; cache entry is deleted on abort/error
7398
- // so the next request retries fresh.
7399
- if (isGlaze) {
7400
- const maskKey = this.getTileCacheKey(x, y, z);
7401
- let maskPromise = this.reliefMaskCache.get(maskKey);
7402
- if (maskPromise) {
7403
- // LRU touch — move the key to the end of the Map to mark as recently used
7404
- this.reliefMaskCache.delete(maskKey);
7405
- this.reliefMaskCache.set(maskKey, maskPromise);
7406
- }
7407
- if (!maskPromise) {
7408
- maskPromise = (async () => {
7409
- const tileData = await this.getTileFromImage(x, y, z, this.tileSize + 2, signal);
7410
- return ReliefCompositor.composeSwissRelief(tileData[0], this.options, cellSizeMeters, this.tileSize, this.tileSize);
7411
- })();
7412
- this.reliefMaskCache.set(maskKey, maskPromise);
7413
- maskPromise.catch(() => this.reliefMaskCache.delete(maskKey));
7414
- if (this.reliefMaskCache.size > this.reliefMaskCacheMaxSize) {
7415
- const oldestKey = this.reliefMaskCache.keys().next().value;
7416
- if (typeof oldestKey === 'string')
7417
- this.reliefMaskCache.delete(oldestKey);
7418
7614
  }
7419
7615
  }
7420
- if (signal?.aborted)
7421
- return null;
7422
- const reliefMask = await maskPromise;
7423
- if (signal?.aborted)
7424
- return null;
7616
+ // Create generator options with skipTextureFlag applied (don't mutate shared this.options)
7617
+ const generatorOptions = {
7618
+ ...this.options,
7619
+ skipTexture: skipTextureFlag,
7620
+ };
7425
7621
  return this.geo.getMap({
7426
- rasters: [reliefMask],
7427
- width: this.tileSize,
7428
- height: this.tileSize,
7622
+ rasters: [tileData[0]],
7623
+ width: requiredSize,
7624
+ height: requiredSize,
7429
7625
  bounds: bounds ?? [0, 0, 0, 0],
7430
7626
  cellSizeMeters,
7431
- }, this.options, meshMaxError ?? 4.0);
7432
- }
7433
- // ── PATH C: Ordinary bitmap ───────────────────────────────────────────────────
7434
- // Raw raster cached — saves fetch + decompression on revisit.
7435
- // BitmapGenerator re-runs cheaply from the cached TypedArray.
7436
- // Signal is passed so cancelled tiles abort cleanly; cache entry deleted on abort/error.
7437
- const rasterKey = this.getTileCacheKey(x, y, z);
7438
- let rasterPromise = this.rasterCache.get(rasterKey);
7439
- if (rasterPromise) {
7440
- // LRU touch — move the key to the end of the Map to mark as recently used
7441
- this.rasterCache.delete(rasterKey);
7442
- this.rasterCache.set(rasterKey, rasterPromise);
7627
+ }, generatorOptions, resolvedMeshMaxError);
7628
+ })();
7629
+ const entry = {
7630
+ promise: pipeline,
7631
+ controller,
7632
+ callerCount: 1,
7633
+ settled: false,
7634
+ };
7635
+ if (signal && !signal.aborted) {
7636
+ signal.addEventListener('abort', () => {
7637
+ entry.callerCount -= 1;
7638
+ if (entry.callerCount <= 0 && !entry.settled) {
7639
+ entry.controller.abort();
7640
+ }
7641
+ }, { once: true });
7642
+ }
7643
+ entry.promise = pipeline;
7644
+ this.cache.setTileResult(cacheKey, entry);
7645
+ try {
7646
+ const result = await pipeline;
7647
+ entry.settled = true;
7648
+ if (signal?.aborted)
7649
+ return null;
7650
+ return result;
7651
+ }
7652
+ catch (error) {
7653
+ entry.settled = true;
7654
+ this.cache.deleteTileResult(cacheKey);
7655
+ throw error;
7656
+ }
7657
+ }
7658
+ async getGlazeTile(x, y, z, bounds, cellSizeMeters, meshMaxError, signal) {
7659
+ const maskKey = this.cache.getTileCacheKey(x, y, z);
7660
+ let maskPromise = this.cache.getReliefMask(maskKey);
7661
+ if (!maskPromise) {
7662
+ const controller = new AbortController();
7663
+ maskPromise = (async () => {
7664
+ const tileData = await this.getTileFromImage(x, y, z, this.tileSize + 2, controller.signal);
7665
+ return ReliefCompositor.composeSwissRelief(tileData[0], this.options, cellSizeMeters, this.tileSize, this.tileSize);
7666
+ })();
7667
+ this.cache.setReliefMask(maskKey, maskPromise);
7668
+ maskPromise.catch(() => this.cache.deleteReliefMask(maskKey));
7443
7669
  }
7670
+ if (signal?.aborted)
7671
+ return null;
7672
+ const reliefMask = await maskPromise;
7673
+ if (signal?.aborted)
7674
+ return null;
7675
+ return this.geo.getMap({
7676
+ rasters: [reliefMask],
7677
+ width: this.tileSize,
7678
+ height: this.tileSize,
7679
+ bounds: bounds ?? [0, 0, 0, 0],
7680
+ cellSizeMeters,
7681
+ }, this.options, meshMaxError ?? 4.0);
7682
+ }
7683
+ async getBitmapTile(x, y, z, bounds, cellSizeMeters, meshMaxError, signal) {
7684
+ const rasterKey = this.cache.getTileCacheKey(x, y, z);
7685
+ let rasterPromise = this.cache.getRaster(rasterKey);
7444
7686
  if (!rasterPromise) {
7445
- rasterPromise = this.getTileFromImage(x, y, z, this.tileSize, signal);
7446
- this.rasterCache.set(rasterKey, rasterPromise);
7447
- rasterPromise.catch(() => this.rasterCache.delete(rasterKey));
7448
- if (this.rasterCache.size > this.rasterCacheMaxSize) {
7449
- const oldestKey = this.rasterCache.keys().next().value;
7450
- if (typeof oldestKey === 'string')
7451
- this.rasterCache.delete(oldestKey);
7452
- }
7687
+ const controller = new AbortController();
7688
+ rasterPromise = this.getTileFromImage(x, y, z, this.tileSize, controller.signal);
7689
+ this.cache.setRaster(rasterKey, rasterPromise);
7690
+ rasterPromise.catch(() => this.cache.deleteRaster(rasterKey));
7453
7691
  }
7454
7692
  if (signal?.aborted)
7455
7693
  return null;
@@ -7464,111 +7702,117 @@ class CogTiles {
7464
7702
  cellSizeMeters,
7465
7703
  }, this.options, meshMaxError ?? 4.0);
7466
7704
  }
7467
- /**
7468
- * Determines the data type (e.g., "Int32", "Float64") of a GeoTIFF image
7469
- * by reading its TIFF tags.
7470
- *
7471
- * @param {GeoTIFFImage} image - A GeoTIFF.js image.
7472
- * @returns {Promise<string>} - A string representing the data type.
7473
- */
7474
- async getDataTypeFromTags(fileDirectory) {
7475
- const hasSampleFormat = fileDirectory.hasTag('SampleFormat');
7476
- const hasBitsPerSample = fileDirectory.hasTag('BitsPerSample');
7477
- if (!hasSampleFormat || !hasBitsPerSample) {
7478
- console.warn("Missing SampleFormat or BitsPerSample tags, defaulting to UInt8");
7479
- return 'UInt8';
7480
- }
7481
- // In GeoTIFF, BitsPerSample (tag 258) and SampleFormat (tag 339) provide the type info.
7482
- // They can be either a single number or an array if there are multiple samples.
7483
- const sampleFormat = fileDirectory.getValue('SampleFormat'); // Tag 339
7484
- const bitsPerSample = fileDirectory.getValue('BitsPerSample'); // Tag 258
7485
- // If multiple bands exist, we assume all bands share the same type.
7486
- const format = (sampleFormat && typeof sampleFormat.length === 'number' && sampleFormat.length > 0)
7487
- ? sampleFormat[0]
7488
- : sampleFormat;
7489
- const bits = (bitsPerSample && typeof bitsPerSample.length === 'number' && bitsPerSample.length > 0)
7490
- ? bitsPerSample[0]
7491
- : bitsPerSample;
7492
- let typePrefix;
7493
- // 1 = Unsigned Integer, 2 = Signed Integer, 3 = Floating Point
7494
- if (format === 1) {
7495
- typePrefix = 'UInt';
7496
- }
7497
- else if (format === 2) {
7498
- typePrefix = 'Int';
7499
- }
7500
- else if (format === 3) {
7501
- typePrefix = 'Float';
7705
+ async getTileAllBands(x, y, z, meshMaxError, signal, bounds) {
7706
+ if (!this.cog) {
7707
+ return [];
7502
7708
  }
7503
- else {
7504
- typePrefix = 'Unknown';
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;
7505
7719
  }
7506
- return `${typePrefix}${bits}`;
7507
- }
7508
- /**
7509
- * Extracts the noData value from a GeoTIFF.js image.
7510
- * Returns the noData value as a number (including NaN) if available, otherwise undefined.
7511
- *
7512
- * @param {GeoTIFFImage} image - The GeoTIFF.js image.
7513
- * @returns {number|undefined} The noData value, possibly NaN, or undefined if not set or invalid.
7514
- */
7515
- getNoDataValue(image) {
7516
- const noDataRaw = image.getGDALNoData();
7517
- if (noDataRaw === undefined || noDataRaw === null) {
7518
- /* eslint-disable no-console */
7519
- console.warn('No noData value defined — raster might be rendered incorrectly.');
7520
- return undefined;
7720
+ else {
7721
+ resolvedMeshMaxError = meshMaxError;
7521
7722
  }
7522
- const cleaned = String(noDataRaw).replace(/\0/g, '').trim();
7523
- if (cleaned === '') {
7524
- /* eslint-disable no-console */
7525
- console.warn('noData value is an empty string after cleanup.');
7526
- return undefined;
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 });
7527
7727
  }
7528
- const parsed = Number(cleaned);
7529
- // Allow NaN if explicitly declared
7530
- if (cleaned.toLowerCase() === 'nan') {
7531
- return NaN;
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;
7532
7805
  }
7533
- // If not declared as "nan" and still parsed to NaN, it's an error
7534
- if (Number.isNaN(parsed)) {
7535
- /* eslint-disable no-console */
7536
- console.warn(`Failed to parse numeric noData value: '${cleaned}'`);
7537
- return undefined;
7806
+ catch (error) {
7807
+ if (signal?.aborted)
7808
+ return [];
7809
+ console.error('[CogTiles.getTileAllBands] Error fetching all bands:', error);
7810
+ return [];
7538
7811
  }
7539
- return parsed;
7540
7812
  }
7541
- /**
7542
- * Creates a tile buffer of the specified size using a typed array corresponding to the provided data type.
7543
- *
7544
- * @param {string} dataType - A string specifying the data type (e.g., "Int32", "Float64", "UInt16", etc.).
7545
- * @param {number} tileSize - The width/height of the square tile.
7546
- * @param {number} multiplier - Optional multiplier for interleaved buffers (e.g., numChannels).
7547
- * @returns {TypedArray} A typed array buffer of length (tileSize * tileSize * multiplier).
7548
- */
7549
- createTileBuffer(dataType, tileSize, multiplier = 1) {
7550
- const length = tileSize * tileSize * multiplier;
7551
- switch (dataType) {
7552
- case 'UInt8':
7553
- return new Uint8Array(length);
7554
- case 'Int8':
7555
- return new Int8Array(length);
7556
- case 'UInt16':
7557
- return new Uint16Array(length);
7558
- case 'Int16':
7559
- return new Int16Array(length);
7560
- case 'UInt32':
7561
- return new Uint32Array(length);
7562
- case 'Int32':
7563
- return new Int32Array(length);
7564
- case 'Float32':
7565
- return new Float32Array(length);
7566
- case 'Float64':
7567
- return new Float64Array(length);
7568
- default:
7569
- console.warn(`Unsupported data type: ${dataType}, defaulting to Float32`);
7570
- return new Float32Array(length);
7571
- }
7813
+ // Expose legacy API for clearing tile result cache (used by external layers)
7814
+ clearTileResultCache() {
7815
+ this.cache.clearTileResultCache();
7572
7816
  }
7573
7817
  }
7574
7818
 
@@ -7879,10 +8123,10 @@ class CogTerrainLayer extends CompositeLayer {
7879
8123
  }
7880
8124
  if (!this.state.isTiled && shouldReload) ;
7881
8125
  // Update the useChannel option for terrainCogTiles when terrainOptions.useChannel changes.
7882
- if (props?.terrainOptions?.useChannel !== oldProps.terrainOptions?.useChannel) {
8126
+ if (props?.terrainOptions?.useChannel !== oldProps.terrainOptions?.useChannel && this.state.terrainCogTiles) {
7883
8127
  this.state.terrainCogTiles.options.useChannel = props.terrainOptions.useChannel;
7884
- // Trigger a refresh of the tiles
7885
- this.state.terrainCogTiles.options.useChannelIndex = null; // Clear cached index
8128
+ this.state.terrainCogTiles.options.useChannelIndex = null; // Clear derived channel index
8129
+ this.state.terrainCogTiles.clearTileResultCache(); // Invalidate cached tiles from previous channel
7886
8130
  }
7887
8131
  // Update skipTexture when wireframe/operation/disableTexture changes so cache keys are correct
7888
8132
  const newSkipTexture = !!(props?.wireframe || props?.operation === 'terrain' || props?.disableTexture);
@@ -8040,6 +8284,7 @@ class CogTerrainLayer extends CompositeLayer {
8040
8284
  elevationDecoder,
8041
8285
  terrainCogTiles: this.state.terrainCogTiles,
8042
8286
  skipTexture: !!(this.props.wireframe || this.props.operation === 'terrain' || this.props.disableTexture),
8287
+ useChannel: this.props.terrainOptions?.useChannel,
8043
8288
  },
8044
8289
  renderSubLayers: {
8045
8290
  disableTexture: this.props.disableTexture,