@gisatcz/deckgl-geolib 2.3.1-dev.1 → 2.4.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/esm/index.js CHANGED
@@ -4515,37 +4515,46 @@ async function fromArrayBuffer(arrayBuffer, signal) {
4515
4515
 
4516
4516
  // types.ts
4517
4517
  const DefaultGeoImageOptions = {
4518
+ // --- Shared / Data ---
4518
4519
  type: 'image',
4519
- tesselator: 'martini',
4520
4520
  format: undefined,
4521
+ useChannel: null,
4522
+ useChannelIndex: null,
4523
+ noDataValue: undefined,
4524
+ multiplier: 1.0,
4525
+ numOfChannels: undefined,
4526
+ planarConfig: undefined,
4527
+ // --- Mesh generation (terrain only) ---
4528
+ tesselator: 'martini',
4529
+ terrainColor: [133, 133, 133, 255],
4530
+ terrainSkirtHeight: 100,
4531
+ // Default fallback for invalid/nodata elevations. Should be configured based on the dataset's actual range.
4532
+ terrainMinValue: 0,
4533
+ // --- Texture / Visualization ---
4521
4534
  useHeatMap: true,
4522
4535
  useColorsBasedOnValues: false,
4536
+ useColorClasses: false,
4523
4537
  useAutoRange: false,
4524
4538
  useDataForOpacity: false,
4525
4539
  useSingleColor: false,
4526
- useColorClasses: false,
4527
4540
  blurredTexture: true,
4528
4541
  clipLow: null,
4529
4542
  clipHigh: null,
4530
- multiplier: 1.0,
4531
4543
  color: [255, 0, 255, 255],
4532
4544
  colorScale: chroma.brewer.YlOrRd,
4533
4545
  colorScaleValueRange: [0, 255],
4534
4546
  colorsBasedOnValues: undefined,
4535
4547
  colorClasses: undefined,
4536
4548
  alpha: 100,
4537
- useChannel: null,
4538
- useChannelIndex: null,
4539
- noDataValue: undefined,
4540
- numOfChannels: undefined,
4541
4549
  nullColor: [0, 0, 0, 0],
4542
4550
  unidentifiedColor: [0, 0, 0, 0],
4543
4551
  clippedColor: [0, 0, 0, 0],
4544
- terrainColor: [133, 133, 133, 255],
4545
- terrainSkirtHeight: 100,
4546
- // Default fallback for invalid/nodata elevations. Should be configured based on the dataset's actual range.
4547
- terrainMinValue: 0,
4548
- planarConfig: undefined,
4552
+ // --- Kernel-specific (terrain only) ---
4553
+ useSlope: false,
4554
+ useHillshade: false,
4555
+ hillshadeAzimuth: 315,
4556
+ hillshadeAltitude: 45,
4557
+ zFactor: 1,
4549
4558
  };
4550
4559
 
4551
4560
  class Martini {
@@ -5223,29 +5232,384 @@ function updateAttributesForNewEdge({ edge, edgeIndex, attributes, skirtHeight,
5223
5232
  newTriangles[triangle1Offset + 5] = positionsLength / 3 + vertex1Offset;
5224
5233
  }
5225
5234
 
5235
+ // DataUtils.ts
5236
+ function scale(num, inMin, inMax, outMin, outMax) {
5237
+ if (inMax === inMin) {
5238
+ return outMin;
5239
+ }
5240
+ return ((num - inMin) * (outMax - outMin)) / (inMax - inMin) + outMin;
5241
+ }
5242
+
5243
+ class BitmapGenerator {
5244
+ /**
5245
+ * Main entry point: Generates an ImageBitmap from raw raster data.
5246
+ */
5247
+ static async generate(input, options) {
5248
+ const optionsLocal = { ...options };
5249
+ const { rasters, width, height } = input;
5250
+ // NOTE: As of 2026-03, only interleaved rasters (rasters.length === 1) are produced by the main COG tile path.
5251
+ // Planar (rasters.length > 1) is not currently supported in production, but this check is kept for future extension.
5252
+ const isInterleaved = rasters.length === 1;
5253
+ const canvas = document.createElement('canvas');
5254
+ canvas.width = width;
5255
+ canvas.height = height;
5256
+ const c = canvas.getContext('2d');
5257
+ const imageData = c.createImageData(width, height);
5258
+ const size = width * height * 4;
5259
+ const alpha255 = Math.floor(optionsLocal.alpha * 2.55);
5260
+ // Normalize colors using chroma
5261
+ optionsLocal.unidentifiedColor = this.getColorFromChromaType(optionsLocal.unidentifiedColor, alpha255);
5262
+ optionsLocal.nullColor = this.getColorFromChromaType(optionsLocal.nullColor, alpha255);
5263
+ optionsLocal.clippedColor = this.getColorFromChromaType(optionsLocal.clippedColor, alpha255);
5264
+ optionsLocal.color = this.getColorFromChromaType(optionsLocal.color, alpha255);
5265
+ optionsLocal.useChannelIndex ??= optionsLocal.useChannel == null ? null : optionsLocal.useChannel - 1;
5266
+ // Derive channel count from data if not provided
5267
+ // If planar support is added, this logic must be updated to handle both layouts correctly.
5268
+ const numAvailableChannels = optionsLocal.numOfChannels ??
5269
+ (rasters.length === 1 ? rasters[0].length / (width * height) : rasters.length);
5270
+ if (optionsLocal.useChannelIndex == null) {
5271
+ if (isInterleaved) {
5272
+ const ratio = rasters[0].length / (width * height);
5273
+ if (ratio === 1) {
5274
+ if (optionsLocal.useAutoRange) {
5275
+ optionsLocal.colorScaleValueRange = this.getMinMax(rasters[0], optionsLocal);
5276
+ }
5277
+ imageData.data.set(this.getColorValue(rasters[0], optionsLocal, size));
5278
+ } // 3 or 4-band RGB(A) imagery: use per-pixel loop for direct color assignment
5279
+ else if (ratio === 3 || ratio === 4) {
5280
+ let sampleIndex = 0;
5281
+ for (let i = 0; i < size; i += 4) {
5282
+ const rgbColor = [rasters[0][sampleIndex], rasters[0][sampleIndex + 1], rasters[0][sampleIndex + 2]];
5283
+ const isNoData = this.hasPixelsNoData(rgbColor, optionsLocal.noDataValue);
5284
+ imageData.data[i] = isNoData ? optionsLocal.nullColor[0] : rgbColor[0];
5285
+ imageData.data[i + 1] = isNoData ? optionsLocal.nullColor[1] : rgbColor[1];
5286
+ imageData.data[i + 2] = isNoData ? optionsLocal.nullColor[2] : rgbColor[2];
5287
+ imageData.data[i + 3] = isNoData ? optionsLocal.nullColor[3] : (ratio === 4 ? rasters[0][sampleIndex + 3] : alpha255);
5288
+ sampleIndex += ratio;
5289
+ }
5290
+ }
5291
+ }
5292
+ else {
5293
+ let sampleIndex = 0;
5294
+ for (let i = 0; i < size; i += 4) {
5295
+ imageData.data[i] = rasters[0][sampleIndex];
5296
+ imageData.data[i + 1] = rasters[1][sampleIndex];
5297
+ imageData.data[i + 2] = rasters[2][sampleIndex];
5298
+ imageData.data[i + 3] = rasters.length === 4 ? rasters[3][sampleIndex] : alpha255;
5299
+ sampleIndex++;
5300
+ }
5301
+ }
5302
+ }
5303
+ else if (optionsLocal.useChannelIndex < numAvailableChannels && optionsLocal.useChannelIndex >= 0) {
5304
+ const isInterleaved = rasters.length === 1 && numAvailableChannels > 1;
5305
+ const channel = isInterleaved ? rasters[0] : (rasters[optionsLocal.useChannelIndex] ?? rasters[0]);
5306
+ const samplesPerPixel = isInterleaved ? numAvailableChannels : 1;
5307
+ if (optionsLocal.useAutoRange) {
5308
+ optionsLocal.colorScaleValueRange = this.getMinMax(channel, optionsLocal, samplesPerPixel);
5309
+ }
5310
+ imageData.data.set(this.getColorValue(channel, optionsLocal, size, samplesPerPixel));
5311
+ }
5312
+ else {
5313
+ // if user defined channel does not exist
5314
+ /* eslint-disable no-console */
5315
+ console.log(`Defined channel(${options.useChannel}) or channel index(${options.useChannelIndex}) does not exist, choose a different channel or set the useChannel property to null if you want to visualize RGB(A) imagery`);
5316
+ const defaultColorData = this.getDefaultColor(size, optionsLocal.nullColor);
5317
+ defaultColorData.forEach((value, index) => {
5318
+ imageData.data[index] = value;
5319
+ });
5320
+ }
5321
+ // Optimization: Skip Canvas -> PNG encoding -> Base64 string
5322
+ // Return raw GPU-ready ImageBitmap directly
5323
+ // Note: createImageBitmap(imageData) is cleaner, but using the canvas ensures broad compatibility
5324
+ c.putImageData(imageData, 0, 0);
5325
+ const map = await createImageBitmap(canvas);
5326
+ // rasters[0] is the interleaved buffer on the CogTiles path (primary use case).
5327
+ // For planar multi-band GeoTIFFs via GeoImage.getBitmap(), only the first band is exposed here.
5328
+ // Full multi-band raw picking support is tracked in https://github.com/Gisat/deck.gl-geotiff/issues/98
5329
+ return { map, raw: rasters[0], width, height };
5330
+ }
5331
+ static getColorValue(dataArray, options, arrayLength, samplesPerPixel = 1) {
5332
+ // Normalize all colorScale entries for chroma.js compatibility
5333
+ const colorScale = chroma.scale(options.colorScale?.map(c => Array.isArray(c) ? chroma(c) : c)).domain(options.colorScaleValueRange);
5334
+ const colorsArray = new Uint8ClampedArray(arrayLength);
5335
+ const optAlpha = Math.floor(options.alpha * 2.55);
5336
+ const rangeMin = options.colorScaleValueRange[0];
5337
+ const rangeMax = options.colorScaleValueRange.slice(-1)[0];
5338
+ const is8Bit = dataArray instanceof Uint8Array || dataArray instanceof Uint8ClampedArray;
5339
+ const isFloatOrWide = !is8Bit && (dataArray instanceof Float32Array || dataArray instanceof Uint16Array || dataArray instanceof Int16Array);
5340
+ // 1. 8-BIT COMPREHENSIVE LUT
5341
+ // Single-band 8-bit (grayscale or indexed): use LUT for fast mapping
5342
+ if (is8Bit && !options.useDataForOpacity) {
5343
+ const lut = new Uint8ClampedArray(256 * 4);
5344
+ for (let i = 0; i < 256; i++) {
5345
+ if ((options.clipLow != null && i <= options.clipLow) ||
5346
+ (options.clipHigh != null && i >= options.clipHigh)) {
5347
+ lut.set(options.clippedColor, i * 4);
5348
+ }
5349
+ else {
5350
+ lut.set(this.calculateSingleColor(i, colorScale, options, optAlpha), i * 4);
5351
+ }
5352
+ }
5353
+ for (let i = 0, sampleIndex = (options.useChannelIndex ?? 0); i < arrayLength; i += 4, sampleIndex += samplesPerPixel) {
5354
+ const lutIdx = dataArray[sampleIndex] * 4;
5355
+ colorsArray[i] = lut[lutIdx];
5356
+ colorsArray[i + 1] = lut[lutIdx + 1];
5357
+ colorsArray[i + 2] = lut[lutIdx + 2];
5358
+ colorsArray[i + 3] = lut[lutIdx + 3];
5359
+ }
5360
+ return colorsArray;
5361
+ }
5362
+ // 2. FLOAT / 16-BIT LUT (HEATMAP ONLY)
5363
+ if (isFloatOrWide && options.useHeatMap && !options.useDataForOpacity) {
5364
+ const LUT_SIZE = 1024;
5365
+ const lut = new Uint8ClampedArray(LUT_SIZE * 4);
5366
+ const rangeSpan = (rangeMax - rangeMin) || 1;
5367
+ for (let i = 0; i < LUT_SIZE; i++) {
5368
+ const domainVal = rangeMin + (i / (LUT_SIZE - 1)) * rangeSpan;
5369
+ if ((options.clipLow != null && domainVal <= options.clipLow) ||
5370
+ (options.clipHigh != null && domainVal >= options.clipHigh)) {
5371
+ lut.set(options.clippedColor, i * 4);
5372
+ }
5373
+ else {
5374
+ const rgb = colorScale(domainVal).rgb();
5375
+ lut[i * 4] = rgb[0];
5376
+ lut[i * 4 + 1] = rgb[1];
5377
+ lut[i * 4 + 2] = rgb[2];
5378
+ lut[i * 4 + 3] = optAlpha;
5379
+ }
5380
+ }
5381
+ for (let i = 0, sampleIndex = (options.useChannelIndex ?? 0); i < arrayLength; i += 4, sampleIndex += samplesPerPixel) {
5382
+ const val = dataArray[sampleIndex];
5383
+ if (this.isInvalid(val, options)) {
5384
+ colorsArray.set(this.getInvalidColor(val, options), i);
5385
+ }
5386
+ else {
5387
+ const t = (val - rangeMin) / rangeSpan;
5388
+ const lutIdx = Math.min(LUT_SIZE - 1, Math.max(0, Math.floor(t * (LUT_SIZE - 1)))) * 4;
5389
+ colorsArray[i] = lut[lutIdx];
5390
+ colorsArray[i + 1] = lut[lutIdx + 1];
5391
+ colorsArray[i + 2] = lut[lutIdx + 2];
5392
+ colorsArray[i + 3] = lut[lutIdx + 3];
5393
+ }
5394
+ }
5395
+ return colorsArray;
5396
+ }
5397
+ // 3. FALLBACK LOOP (Categorical Float, Opacity, or Single Color)
5398
+ let sampleIndex = options.useChannelIndex ?? 0;
5399
+ for (let i = 0; i < arrayLength; i += 4) {
5400
+ const val = dataArray[sampleIndex];
5401
+ let color;
5402
+ if ((options.clipLow != null && val <= options.clipLow) || (options.clipHigh != null && val >= options.clipHigh)) {
5403
+ color = options.clippedColor;
5404
+ }
5405
+ else {
5406
+ color = this.calculateSingleColor(val, colorScale, options, optAlpha);
5407
+ }
5408
+ if (options.useDataForOpacity && !this.isInvalid(val, options)) {
5409
+ color[3] = scale(val, rangeMin, rangeMax, 0, 255);
5410
+ }
5411
+ colorsArray.set(color, i);
5412
+ sampleIndex += samplesPerPixel;
5413
+ }
5414
+ return colorsArray;
5415
+ }
5416
+ static calculateSingleColor(val, colorScale, options, alpha) {
5417
+ if (this.isInvalid(val, options)) {
5418
+ return options.nullColor;
5419
+ }
5420
+ // Color mode priority (most specific wins):
5421
+ // 1. useSingleColor
5422
+ // 2. useColorClasses
5423
+ // 3. useColorsBasedOnValues
5424
+ // 4. useHeatMap
5425
+ // Only the first enabled mode is used.
5426
+ if (options.useSingleColor) {
5427
+ return options.color;
5428
+ }
5429
+ else if (options.useColorClasses) {
5430
+ const index = this.findClassIndex(val, options);
5431
+ return index > -1 ? [...chroma(Array.isArray(options.colorClasses[index][0]) ? chroma(options.colorClasses[index][0]) : options.colorClasses[index][0]).rgb(), alpha] : options.unidentifiedColor;
5432
+ }
5433
+ else if (options.useColorsBasedOnValues) {
5434
+ const match = options.colorsBasedOnValues?.find(([v]) => v === val);
5435
+ return match ? [...chroma(Array.isArray(match[1]) ? chroma(match[1]) : match[1]).rgb(), alpha] : options.unidentifiedColor;
5436
+ }
5437
+ else if (options.useHeatMap) {
5438
+ return [...colorScale(val).rgb(), alpha];
5439
+ }
5440
+ return options.unidentifiedColor;
5441
+ }
5442
+ static findClassIndex(val, options) {
5443
+ if (!options.colorClasses)
5444
+ return -1;
5445
+ for (let i = 0; i < options.colorClasses.length; i++) {
5446
+ const [, [min, max], bounds] = options.colorClasses[i];
5447
+ const [incMin, incMax] = bounds || (i === options.colorClasses.length - 1 ? [true, true] : [true, false]);
5448
+ if ((incMin ? val >= min : val > min) && (incMax ? val <= max : val < max))
5449
+ return i;
5450
+ }
5451
+ return -1;
5452
+ }
5453
+ static getDefaultColor(size, nullColor) {
5454
+ const colorsArray = new Uint8ClampedArray(size);
5455
+ for (let i = 0; i < size; i += 4) {
5456
+ [colorsArray[i], colorsArray[i + 1], colorsArray[i + 2], colorsArray[i + 3]] = nullColor;
5457
+ }
5458
+ return colorsArray;
5459
+ }
5460
+ static isInvalid(val, options) {
5461
+ return Number.isNaN(val) || (options.noDataValue !== undefined && val === options.noDataValue);
5462
+ }
5463
+ static getInvalidColor(val, options) {
5464
+ return options.nullColor;
5465
+ }
5466
+ static getMinMax(array, options, samplesPerPixel = 1) {
5467
+ let max = -Infinity, min = Infinity;
5468
+ for (let i = (options.useChannelIndex ?? 0); i < array.length; i += samplesPerPixel) {
5469
+ const val = array[i];
5470
+ if (!this.isInvalid(val, options)) {
5471
+ if (val > max)
5472
+ max = val;
5473
+ if (val < min)
5474
+ min = val;
5475
+ }
5476
+ }
5477
+ return max === -Infinity ? (options.colorScaleValueRange || [0, 255]) : [min, max];
5478
+ }
5479
+ static getColorFromChromaType(color, alpha = 255) {
5480
+ return (!Array.isArray(color) || color.length !== 4) ? [...chroma(color).rgb(), alpha] : color;
5481
+ }
5482
+ static hasPixelsNoData(pixels, noData) {
5483
+ return noData !== undefined && pixels.every(p => p === noData);
5484
+ }
5485
+ }
5486
+
5487
+ /**
5488
+ * KernelGenerator — 3×3 neighborhood kernel calculations on elevation rasters.
5489
+ *
5490
+ * Input contract: a Float32Array of 258×258 elevation values (row-major).
5491
+ * Edge pixels (row/col 0 and 257) are used only as kernel neighbors and do
5492
+ * not appear in the output.
5493
+ * Output: Float32Array of 256×256 computed values.
5494
+ */
5495
+ class KernelGenerator {
5496
+ /**
5497
+ * Calculates slope (0–90 degrees) for each pixel using Horn's method.
5498
+ *
5499
+ * @param src Float32Array of 258×258 elevation values (row-major)
5500
+ * @param cellSize Cell size in meters per pixel
5501
+ * @param zFactor Vertical exaggeration factor (default 1)
5502
+ * @param noDataValue Elevation value treated as noData; output is NaN for those pixels
5503
+ */
5504
+ static calculateSlope(src, cellSize, zFactor = 1, noDataValue) {
5505
+ const OUT = 256;
5506
+ const IN = 258;
5507
+ const out = new Float32Array(OUT * OUT);
5508
+ for (let r = 0; r < OUT; r++) {
5509
+ for (let c = 0; c < OUT; c++) {
5510
+ // 3×3 neighborhood in the 258×258 input, centered at (r+1, c+1)
5511
+ const base = r * IN + c;
5512
+ const z5 = src[base + IN + 1]; // center pixel
5513
+ if (noDataValue !== undefined && z5 === noDataValue) {
5514
+ out[r * OUT + c] = NaN;
5515
+ continue;
5516
+ }
5517
+ const z1 = src[base]; // nw
5518
+ const z2 = src[base + 1]; // n
5519
+ const z3 = src[base + 2]; // ne
5520
+ const z4 = src[base + IN]; // w
5521
+ const z6 = src[base + IN + 2]; // e
5522
+ const z7 = src[base + 2 * IN]; // sw
5523
+ const z8 = src[base + 2 * IN + 1]; // s
5524
+ const z9 = src[base + 2 * IN + 2]; // se
5525
+ const dzdx = ((z3 + 2 * z6 + z9) - (z1 + 2 * z4 + z7)) / (8 * cellSize);
5526
+ const dzdy = ((z7 + 2 * z8 + z9) - (z1 + 2 * z2 + z3)) / (8 * cellSize);
5527
+ const slopeRad = Math.atan(zFactor * Math.sqrt(dzdx * dzdx + dzdy * dzdy));
5528
+ out[r * OUT + c] = slopeRad * (180 / Math.PI);
5529
+ }
5530
+ }
5531
+ return out;
5532
+ }
5533
+ /**
5534
+ * Calculates hillshade (0–255 grayscale) for each pixel.
5535
+ * Follows the ESRI hillshade algorithm convention.
5536
+ *
5537
+ * @param src Float32Array of 258×258 elevation values (row-major)
5538
+ * @param azimuth Sun azimuth in degrees (default 315 = NW)
5539
+ * @param altitude Sun altitude above horizon in degrees (default 45)
5540
+ * @param cellSize Cell size in meters per pixel
5541
+ * @param zFactor Vertical exaggeration factor (default 1)
5542
+ * @param noDataValue Elevation value treated as noData; output is NaN for those pixels
5543
+ */
5544
+ static calculateHillshade(src, cellSize, azimuth = 315, altitude = 45, zFactor = 1, noDataValue) {
5545
+ const OUT = 256;
5546
+ const IN = 258;
5547
+ const out = new Float32Array(OUT * OUT);
5548
+ const zenithRad = (90 - altitude) * (Math.PI / 180);
5549
+ let azimuthMath = 360 - azimuth + 90;
5550
+ if (azimuthMath >= 360)
5551
+ azimuthMath -= 360;
5552
+ const azimuthRad = azimuthMath * (Math.PI / 180);
5553
+ for (let r = 0; r < OUT; r++) {
5554
+ for (let c = 0; c < OUT; c++) {
5555
+ const base = r * IN + c;
5556
+ const z5 = src[base + IN + 1]; // center pixel
5557
+ if (noDataValue !== undefined && z5 === noDataValue) {
5558
+ out[r * OUT + c] = NaN;
5559
+ continue;
5560
+ }
5561
+ const z1 = src[base]; // nw
5562
+ const z2 = src[base + 1]; // n
5563
+ const z3 = src[base + 2]; // ne
5564
+ const z4 = src[base + IN]; // w
5565
+ const z6 = src[base + IN + 2]; // e
5566
+ const z7 = src[base + 2 * IN]; // sw
5567
+ const z8 = src[base + 2 * IN + 1]; // s
5568
+ const z9 = src[base + 2 * IN + 2]; // se
5569
+ const dzdx = ((z3 + 2 * z6 + z9) - (z1 + 2 * z4 + z7)) / (8 * cellSize);
5570
+ // dzdy: north minus south (geographic convention — top rows minus bottom rows in raster)
5571
+ const dzdy = ((z1 + 2 * z2 + z3) - (z7 + 2 * z8 + z9)) / (8 * cellSize);
5572
+ const slopeRad = Math.atan(zFactor * Math.sqrt(dzdx * dzdx + dzdy * dzdy));
5573
+ const aspectRad = Math.atan2(dzdy, -dzdx);
5574
+ const hillshade = 255 * (Math.cos(zenithRad) * Math.cos(slopeRad) +
5575
+ Math.sin(zenithRad) * Math.sin(slopeRad) * Math.cos(azimuthRad - aspectRad));
5576
+ out[r * OUT + c] = Math.max(0, Math.min(255, hillshade));
5577
+ }
5578
+ }
5579
+ return out;
5580
+ }
5581
+ }
5582
+
5226
5583
  class TerrainGenerator {
5227
- static generate(input, options, meshMaxError) {
5584
+ static async generate(input, options, meshMaxError) {
5228
5585
  const { width, height } = input;
5586
+ const isKernel = width === 258;
5229
5587
  // 1. Compute Terrain Data (Extract Elevation)
5230
5588
  const terrain = this.computeTerrainData(input, options);
5589
+ // For kernel tiles, the mesh uses the inner 257×257 sub-grid (rows 1–257, cols 1–257)
5590
+ // so that row 0 / col 0 (kernel padding) is dropped while the bottom/right stitching
5591
+ // overlap is preserved.
5592
+ const meshTerrain = isKernel ? this.extractMeshRaster(terrain) : terrain;
5593
+ const meshWidth = isKernel ? 257 : width;
5594
+ const meshHeight = isKernel ? 257 : height;
5231
5595
  // 2. Tesselate (Generate Mesh)
5232
5596
  const { terrainSkirtHeight } = options;
5233
5597
  let mesh;
5234
5598
  switch (options.tesselator) {
5235
5599
  case 'martini':
5236
- mesh = this.getMartiniTileMesh(meshMaxError, width, terrain);
5600
+ mesh = this.getMartiniTileMesh(meshMaxError, meshWidth, meshTerrain);
5237
5601
  break;
5238
5602
  case 'delatin':
5239
- mesh = this.getDelatinTileMesh(meshMaxError, width, height, terrain);
5603
+ mesh = this.getDelatinTileMesh(meshMaxError, meshWidth, meshHeight, meshTerrain);
5240
5604
  break;
5241
5605
  default:
5242
5606
  // Intentional: default to Martini for any unspecified or unrecognized tesselator.
5243
- mesh = this.getMartiniTileMesh(meshMaxError, width, terrain);
5607
+ mesh = this.getMartiniTileMesh(meshMaxError, meshWidth, meshTerrain);
5244
5608
  break;
5245
5609
  }
5246
5610
  const { vertices } = mesh;
5247
5611
  let { triangles } = mesh;
5248
- let attributes = this.getMeshAttributes(vertices, terrain, width, height, input.bounds);
5612
+ let attributes = this.getMeshAttributes(vertices, meshTerrain, meshWidth, meshHeight, input.bounds);
5249
5613
  // Compute bounding box before adding skirt so that z values are not skewed
5250
5614
  const boundingBox = getMeshBoundingBox(attributes);
5251
5615
  if (terrainSkirtHeight) {
@@ -5266,14 +5630,91 @@ class TerrainGenerator {
5266
5630
  indices: { value: Uint32Array.from(triangles), size: 1 },
5267
5631
  attributes,
5268
5632
  };
5269
- const gridWidth = width === 257 ? 257 : width + 1;
5270
- const gridHeight = height === 257 ? 257 : height + 1;
5271
- return {
5633
+ // For kernel tiles, raw holds the 257×257 mesh elevation (same as non-kernel).
5634
+ // gridWidth/gridHeight reflect the mesh dimensions.
5635
+ const gridWidth = meshWidth === 257 ? 257 : meshWidth + 1;
5636
+ const gridHeight = meshHeight === 257 ? 257 : meshHeight + 1;
5637
+ const tileResult = {
5272
5638
  map,
5273
- raw: terrain,
5639
+ raw: meshTerrain,
5274
5640
  width: gridWidth,
5275
- height: gridHeight
5641
+ height: gridHeight,
5276
5642
  };
5643
+ // 3. Kernel path: compute slope or hillshade, store as rawDerived, generate texture
5644
+ if (isKernel && (options.useSlope || options.useHillshade)) {
5645
+ // Use pre-computed geographic cellSize (meters/pixel) from tile indices.
5646
+ // Falls back to bounds-derived estimate if not provided.
5647
+ const cellSize = input.cellSizeMeters ?? ((input.bounds[2] - input.bounds[0]) / 256);
5648
+ const zFactor = options.zFactor ?? 1;
5649
+ if (options.useSlope && options.useHillshade) {
5650
+ console.warn('[TerrainGenerator] useSlope and useHillshade are mutually exclusive; useSlope takes precedence.');
5651
+ }
5652
+ // Build a separate raster for kernel computation that preserves noData samples.
5653
+ const kernelTerrain = new Float32Array(terrain.length);
5654
+ const sourceRaster = input.rasters[0];
5655
+ const noData = options.noDataValue;
5656
+ if (noData !== undefined &&
5657
+ noData !== null &&
5658
+ sourceRaster &&
5659
+ sourceRaster.length === terrain.length) {
5660
+ for (let i = 0; i < terrain.length; i++) {
5661
+ // If the source raster marks this sample as noData, keep it as noData for the kernel.
5662
+ // Otherwise, use the processed terrain elevation value.
5663
+ // eslint-disable-next-line eqeqeq
5664
+ kernelTerrain[i] = sourceRaster[i] == noData ? noData : terrain[i];
5665
+ }
5666
+ }
5667
+ else {
5668
+ // Fallback: no usable noData metadata or mismatched lengths; mirror existing behavior.
5669
+ kernelTerrain.set(terrain);
5670
+ }
5671
+ let kernelOutput;
5672
+ if (options.useSlope) {
5673
+ kernelOutput = KernelGenerator.calculateSlope(kernelTerrain, cellSize, zFactor, options.noDataValue);
5674
+ }
5675
+ else {
5676
+ kernelOutput = KernelGenerator.calculateHillshade(kernelTerrain, cellSize, options.hillshadeAzimuth ?? 315, options.hillshadeAltitude ?? 45, zFactor, options.noDataValue);
5677
+ }
5678
+ tileResult.rawDerived = kernelOutput;
5679
+ if (this.hasVisualizationOptions(options)) {
5680
+ const bitmapResult = await BitmapGenerator.generate({ width: 256, height: 256, rasters: [kernelOutput] }, { ...options, type: 'image' });
5681
+ tileResult.texture = bitmapResult.map;
5682
+ }
5683
+ }
5684
+ else if (this.hasVisualizationOptions(options)) {
5685
+ // 4. Non-kernel path: crop 257→256, generate texture from elevation
5686
+ const cropped = this.cropRaster(meshTerrain, gridWidth, gridHeight, 256, 256);
5687
+ const bitmapResult = await BitmapGenerator.generate({ width: 256, height: 256, rasters: [cropped] }, { ...options, type: 'image' });
5688
+ tileResult.texture = bitmapResult.map;
5689
+ }
5690
+ return tileResult;
5691
+ }
5692
+ /** Extracts rows 1–257, cols 1–257 from a 258×258 terrain array → 257×257 for mesh generation. */
5693
+ static extractMeshRaster(terrain258) {
5694
+ const MESH = 257;
5695
+ const IN = 258;
5696
+ const out = new Float32Array(MESH * MESH);
5697
+ for (let r = 0; r < MESH; r++) {
5698
+ for (let c = 0; c < MESH; c++) {
5699
+ out[r * MESH + c] = terrain258[(r + 1) * IN + (c + 1)];
5700
+ }
5701
+ }
5702
+ return out;
5703
+ }
5704
+ static hasVisualizationOptions(options) {
5705
+ return !!(options.useHeatMap ||
5706
+ options.useSingleColor ||
5707
+ options.useColorsBasedOnValues ||
5708
+ options.useColorClasses);
5709
+ }
5710
+ static cropRaster(src, srcWidth, _srcHeight, dstWidth, dstHeight) {
5711
+ const out = new Float32Array(dstWidth * dstHeight);
5712
+ for (let y = 0; y < dstHeight; y++) {
5713
+ for (let x = 0; x < dstWidth; x++) {
5714
+ out[y * dstWidth + x] = src[y * srcWidth + x];
5715
+ }
5716
+ }
5717
+ return out;
5277
5718
  }
5278
5719
  /**
5279
5720
  * Decodes raw raster data into a Float32Array of elevation values.
@@ -5288,12 +5729,16 @@ class TerrainGenerator {
5288
5729
  const channel = isPlanar
5289
5730
  ? (rasters[optionsLocal.useChannelIndex ?? 0] ?? rasters[0])
5290
5731
  : rasters[0];
5291
- const terrain = new Float32Array((width === 257 ? width : width + 1) * (height === 257 ? height : height + 1));
5732
+ const isKernel = width === 258;
5733
+ const isStitched = width === 257;
5734
+ // Kernel: 258×258 flat array. Stitched: 257×257. Default: (width+1)×(height+1) with backfill.
5735
+ const outWidth = isKernel ? 258 : (isStitched ? 257 : width + 1);
5736
+ const outHeight = isKernel ? 258 : (isStitched ? 257 : height + 1);
5737
+ const terrain = new Float32Array(outWidth * outHeight);
5292
5738
  const samplesPerPixel = isPlanar ? 1 : (channel.length / (width * height));
5293
5739
  // If planar, we already selected the correct array, so start at index 0.
5294
5740
  // If interleaved, start at the index of the desired channel.
5295
5741
  let pixel = isPlanar ? 0 : (optionsLocal.useChannelIndex ?? 0);
5296
- const isStitched = width === 257;
5297
5742
  const fallbackValue = options.terrainMinValue ?? 0;
5298
5743
  for (let y = 0; y < height; y++) {
5299
5744
  for (let x = 0; x < width; x++) {
@@ -5308,13 +5753,13 @@ class TerrainGenerator {
5308
5753
  if (Number.isNaN(elevationValue) || elevationValue < -34e37 || elevationValue > 3.4e38) {
5309
5754
  elevationValue = fallbackValue;
5310
5755
  }
5311
- // If stitched (257), fill linearly. If 256, fill with stride for padding.
5312
- const index = isStitched ? (y * width + x) : (y * (width + 1) + x);
5756
+ // Kernel/Stitched: fill linearly. Default (256): fill with stride for padding.
5757
+ const index = (isKernel || isStitched) ? (y * width + x) : (y * (width + 1) + x);
5313
5758
  terrain[index] = elevationValue;
5314
5759
  pixel += samplesPerPixel;
5315
5760
  }
5316
5761
  }
5317
- if (!isStitched) {
5762
+ if (!isKernel && !isStitched) {
5318
5763
  // backfill bottom border
5319
5764
  for (let i = (width + 1) * width, x = 0; x < width; x++, i++) {
5320
5765
  terrain[i] = terrain[i - width - 1];
@@ -5375,313 +5820,6 @@ class TerrainGenerator {
5375
5820
  }
5376
5821
  }
5377
5822
 
5378
- // DataUtils.ts
5379
- function scale(num, inMin, inMax, outMin, outMax) {
5380
- if (inMax === inMin) {
5381
- return outMin;
5382
- }
5383
- return ((num - inMin) * (outMax - outMin)) / (inMax - inMin) + outMin;
5384
- }
5385
-
5386
- class BitmapGenerator {
5387
- static async generate(input, options) {
5388
- const optionsLocal = { ...options };
5389
- const { rasters, width, height } = input;
5390
- const channels = rasters.length;
5391
- const canvas = document.createElement('canvas');
5392
- canvas.width = width;
5393
- canvas.height = height;
5394
- const c = canvas.getContext('2d');
5395
- const imageData = c.createImageData(width, height);
5396
- let r;
5397
- let g;
5398
- let b;
5399
- let a;
5400
- const size = width * height * 4;
5401
- const alpha255 = Math.floor(optionsLocal.alpha * 2.55);
5402
- optionsLocal.unidentifiedColor = this.getColorFromChromaType(optionsLocal.unidentifiedColor, alpha255);
5403
- optionsLocal.nullColor = this.getColorFromChromaType(optionsLocal.nullColor, alpha255);
5404
- optionsLocal.clippedColor = this.getColorFromChromaType(optionsLocal.clippedColor, alpha255);
5405
- optionsLocal.color = this.getColorFromChromaType(optionsLocal.color, alpha255);
5406
- optionsLocal.useChannelIndex ??= optionsLocal.useChannel == null ? null : optionsLocal.useChannel - 1;
5407
- // Derive channel count from data if not provided
5408
- const numAvailableChannels = optionsLocal.numOfChannels ?? (rasters.length === 1 ? rasters[0].length / (width * height) : rasters.length);
5409
- if (optionsLocal.useChannelIndex == null) {
5410
- if (channels === 1) {
5411
- if (rasters[0].length / (width * height) === 1) {
5412
- const channel = rasters[0];
5413
- // AUTO RANGE
5414
- if (optionsLocal.useAutoRange) {
5415
- optionsLocal.colorScaleValueRange = this.getMinMax(channel, optionsLocal);
5416
- }
5417
- // SINGLE CHANNEL
5418
- const colorData = this.getColorValue(channel, optionsLocal, size);
5419
- imageData.data.set(colorData);
5420
- }
5421
- // RGB values in one channel
5422
- if (rasters[0].length / (width * height) === 3) {
5423
- let pixel = 0;
5424
- for (let idx = 0; idx < size; idx += 4) {
5425
- const rgbColor = [rasters[0][pixel], rasters[0][pixel + 1], rasters[0][pixel + 2]];
5426
- const rgbaColor = this.hasPixelsNoData(rgbColor, optionsLocal.noDataValue)
5427
- ? optionsLocal.nullColor
5428
- : [...rgbColor, Math.floor(optionsLocal.alpha * 2.55)];
5429
- [imageData.data[idx], imageData.data[idx + 1], imageData.data[idx + 2], imageData.data[idx + 3]] = rgbaColor;
5430
- pixel += 3;
5431
- }
5432
- }
5433
- if (rasters[0].length / (width * height) === 4) {
5434
- rasters[0].forEach((value, index) => {
5435
- imageData.data[index] = value;
5436
- });
5437
- }
5438
- }
5439
- if (channels === 3) {
5440
- // RGB
5441
- let pixel = 0;
5442
- const alphaConst = Math.floor(optionsLocal.alpha * 2.55);
5443
- for (let i = 0; i < size; i += 4) {
5444
- r = rasters[0][pixel];
5445
- g = rasters[1][pixel];
5446
- b = rasters[2][pixel];
5447
- a = alphaConst;
5448
- imageData.data[i] = r;
5449
- imageData.data[i + 1] = g;
5450
- imageData.data[i + 2] = b;
5451
- imageData.data[i + 3] = a;
5452
- pixel += 1;
5453
- }
5454
- }
5455
- if (channels === 4) {
5456
- // RGBA
5457
- let pixel = 0;
5458
- const alphaConst = Math.floor(optionsLocal.alpha * 2.55);
5459
- for (let i = 0; i < size; i += 4) {
5460
- r = rasters[0][pixel];
5461
- g = rasters[1][pixel];
5462
- b = rasters[2][pixel];
5463
- a = alphaConst;
5464
- imageData.data[i] = r;
5465
- imageData.data[i + 1] = g;
5466
- imageData.data[i + 2] = b;
5467
- imageData.data[i + 3] = a;
5468
- pixel += 1;
5469
- }
5470
- }
5471
- }
5472
- else if (optionsLocal.useChannelIndex < numAvailableChannels && optionsLocal.useChannelIndex >= 0) {
5473
- const isInterleaved = rasters.length === 1 && numAvailableChannels > 1;
5474
- const channel = isInterleaved ? rasters[0] : (rasters[optionsLocal.useChannelIndex] ?? rasters[0]);
5475
- const samplesPerPixel = isInterleaved ? numAvailableChannels : 1;
5476
- // AUTO RANGE
5477
- if (optionsLocal.useAutoRange) {
5478
- optionsLocal.colorScaleValueRange = this.getMinMax(channel, optionsLocal, samplesPerPixel);
5479
- }
5480
- const colorData = this.getColorValue(channel, optionsLocal, size, samplesPerPixel);
5481
- imageData.data.set(colorData);
5482
- }
5483
- else {
5484
- // if user defined channel does not exist
5485
- /* eslint-disable no-console */
5486
- console.log(`Defined channel(${options.useChannel}) or channel index(${options.useChannelIndex}) does not exist, choose a different channel or set the useChannel property to null if you want to visualize RGB(A) imagery`);
5487
- const defaultColorData = this.getDefaultColor(size, optionsLocal.nullColor);
5488
- defaultColorData.forEach((value, index) => {
5489
- imageData.data[index] = value;
5490
- });
5491
- }
5492
- // Optimization: Skip Canvas -> PNG encoding -> Base64 string
5493
- // Return raw GPU-ready ImageBitmap directly
5494
- // Note: createImageBitmap(imageData) is cleaner, but using the canvas ensures broad compatibility
5495
- c.putImageData(imageData, 0, 0);
5496
- const map = await createImageBitmap(canvas);
5497
- return {
5498
- map,
5499
- // rasters[0] is the interleaved buffer on the CogTiles path (primary use case).
5500
- // For planar multi-band GeoTIFFs via GeoImage.getBitmap(), only the first band is exposed here.
5501
- // Full multi-band raw picking support is tracked in https://github.com/Gisat/deck.gl-geotiff/issues/98
5502
- raw: rasters[0],
5503
- width,
5504
- height
5505
- };
5506
- }
5507
- static getMinMax(array, options, samplesPerPixel = 1) {
5508
- let maxValue = -Infinity;
5509
- let minValue = Infinity;
5510
- let foundValid = false;
5511
- let pixel = samplesPerPixel === 1 ? 0 : (options.useChannelIndex ?? 0);
5512
- for (let idx = pixel; idx < array.length; idx += samplesPerPixel) {
5513
- if (options.noDataValue === undefined || array[idx] !== options.noDataValue) {
5514
- if (array[idx] > maxValue)
5515
- maxValue = array[idx];
5516
- if (array[idx] < minValue)
5517
- minValue = array[idx];
5518
- foundValid = true;
5519
- }
5520
- }
5521
- if (!foundValid) {
5522
- return options.colorScaleValueRange || [0, 255];
5523
- }
5524
- return [minValue, maxValue];
5525
- }
5526
- static getColorValue(dataArray, options, arrayLength, samplesPerPixel = 1) {
5527
- const colorScale = chroma.scale(options.colorScale).domain(options.colorScaleValueRange);
5528
- let pixel = samplesPerPixel === 1 ? 0 : (options.useChannelIndex ?? 0);
5529
- const colorsArray = new Uint8ClampedArray(arrayLength);
5530
- const cbvInput = options.colorsBasedOnValues ?? [];
5531
- const classesInput = options.colorClasses ?? [];
5532
- const optUseColorsBasedOnValues = options.useColorsBasedOnValues && cbvInput.length > 0;
5533
- const optUseColorClasses = options.useColorClasses && classesInput.length > 0;
5534
- const dataValues = optUseColorsBasedOnValues ? cbvInput.map(([first]) => first) : [];
5535
- const colorValues = optUseColorsBasedOnValues ? cbvInput.map(([, second]) => [...chroma(second).rgb(), Math.floor(options.alpha * 2.55)]) : [];
5536
- const colorClasses = optUseColorClasses ? classesInput.map(([color]) => [...chroma(color).rgb(), Math.floor(options.alpha * 2.55)]) : [];
5537
- const dataIntervals = optUseColorClasses ? classesInput.map(([, interval]) => interval) : [];
5538
- const dataIntervalBounds = optUseColorClasses ? classesInput.map(([, , bounds], index) => {
5539
- if (bounds !== undefined)
5540
- return bounds;
5541
- if (index === classesInput.length - 1)
5542
- return [true, true];
5543
- return [true, false];
5544
- }) : [];
5545
- // Pre-calculate Loop Variables to avoid object lookup in loop
5546
- const optNoData = options.noDataValue;
5547
- const optClipLow = options.clipLow;
5548
- const optClipHigh = options.clipHigh;
5549
- const optClippedColor = options.clippedColor;
5550
- const optUseHeatMap = options.useHeatMap;
5551
- const optUseSingleColor = options.useSingleColor;
5552
- const optUseDataForOpacity = options.useDataForOpacity;
5553
- const optColor = options.color;
5554
- const optUnidentifiedColor = options.unidentifiedColor;
5555
- const optNullColor = options.nullColor;
5556
- const optAlpha = Math.floor(options.alpha * 2.55);
5557
- const rangeMin = options.colorScaleValueRange[0];
5558
- const rangeMax = options.colorScaleValueRange.slice(-1)[0];
5559
- // LOOKUP TABLE OPTIMIZATION (for 8-bit data)
5560
- // If the data is Uint8 (0-255), we can pre-calculate the result for every possible value.
5561
- const is8Bit = dataArray instanceof Uint8Array || dataArray instanceof Uint8ClampedArray;
5562
- // The LUT optimization is only applied for 8-bit data when `useDataForOpacity` is false.
5563
- // `useDataForOpacity` is excluded because it requires the raw data value for
5564
- // dynamic opacity scaling. All other visualization modes (HeatMap, Categorical,
5565
- // Classes, Single Color) are pre-calculated into the LUT for maximum performance.
5566
- if (is8Bit && !optUseDataForOpacity) {
5567
- // Create LUT: 256 values * 4 channels (RGBA)
5568
- const lut = new Uint8ClampedArray(256 * 4);
5569
- for (let i = 0; i < 256; i++) {
5570
- let r = optNullColor[0], g = optNullColor[1], b = optNullColor[2], a = optNullColor[3];
5571
- // Logic mirroring the pixel loop
5572
- if (optNoData === undefined || i !== optNoData) {
5573
- if ((optClipLow != null && i <= optClipLow) || (optClipHigh != null && i >= optClipHigh)) {
5574
- [r, g, b, a] = optClippedColor;
5575
- }
5576
- else {
5577
- let c = [r, g, b, a];
5578
- if (optUseHeatMap) {
5579
- const rgb = colorScale(i).rgb();
5580
- c = [rgb[0], rgb[1], rgb[2], optAlpha];
5581
- }
5582
- else if (optUseColorsBasedOnValues) {
5583
- const index = dataValues.indexOf(i);
5584
- c = (index > -1) ? colorValues[index] : optUnidentifiedColor;
5585
- }
5586
- else if (optUseColorClasses) {
5587
- const index = this.findClassIndex(i, dataIntervals, dataIntervalBounds);
5588
- c = (index > -1) ? colorClasses[index] : optUnidentifiedColor;
5589
- }
5590
- else if (optUseSingleColor) {
5591
- c = optColor;
5592
- }
5593
- [r, g, b, a] = c;
5594
- }
5595
- }
5596
- lut[i * 4] = r;
5597
- lut[i * 4 + 1] = g;
5598
- lut[i * 4 + 2] = b;
5599
- lut[i * 4 + 3] = a;
5600
- }
5601
- // Fast Apply Loop
5602
- let outIdx = 0;
5603
- const numPixels = arrayLength / 4;
5604
- for (let i = 0; i < numPixels; i++) {
5605
- const val = dataArray[pixel];
5606
- const lutIdx = Math.min(255, Math.max(0, val)) * 4;
5607
- colorsArray[outIdx++] = lut[lutIdx];
5608
- colorsArray[outIdx++] = lut[lutIdx + 1];
5609
- colorsArray[outIdx++] = lut[lutIdx + 2];
5610
- colorsArray[outIdx++] = lut[lutIdx + 3];
5611
- pixel += samplesPerPixel;
5612
- }
5613
- return colorsArray;
5614
- }
5615
- // Standard Loop (Float or non-optimized)
5616
- for (let i = 0; i < arrayLength; i += 4) {
5617
- let r = optNullColor[0], g = optNullColor[1], b = optNullColor[2], a = optNullColor[3];
5618
- const val = dataArray[pixel];
5619
- if ((!Number.isNaN(val)) && (optNoData === undefined || val !== optNoData)) {
5620
- if ((optClipLow != null && val <= optClipLow) || (optClipHigh != null && val >= optClipHigh)) {
5621
- [r, g, b, a] = optClippedColor;
5622
- }
5623
- else {
5624
- let c;
5625
- if (optUseHeatMap) {
5626
- const rgb = colorScale(val).rgb();
5627
- c = [rgb[0], rgb[1], rgb[2], optAlpha];
5628
- }
5629
- else if (optUseColorsBasedOnValues) {
5630
- const index = dataValues.indexOf(val);
5631
- c = (index > -1) ? colorValues[index] : optUnidentifiedColor;
5632
- }
5633
- else if (optUseColorClasses) {
5634
- const index = this.findClassIndex(val, dataIntervals, dataIntervalBounds);
5635
- c = (index > -1) ? colorClasses[index] : optUnidentifiedColor;
5636
- }
5637
- else if (optUseSingleColor) {
5638
- c = optColor;
5639
- }
5640
- if (c) {
5641
- [r, g, b, a] = c;
5642
- }
5643
- if (optUseDataForOpacity) {
5644
- a = scale(val, rangeMin, rangeMax, 0, 255);
5645
- }
5646
- }
5647
- }
5648
- colorsArray[i] = r;
5649
- colorsArray[i + 1] = g;
5650
- colorsArray[i + 2] = b;
5651
- colorsArray[i + 3] = a;
5652
- pixel += samplesPerPixel;
5653
- }
5654
- return colorsArray;
5655
- }
5656
- static findClassIndex(number, intervals, bounds) {
5657
- for (let idx = 0; idx < intervals.length; idx += 1) {
5658
- const [min, max] = intervals[idx];
5659
- const [includeEqualMin, includeEqualMax] = bounds[idx];
5660
- if ((includeEqualMin ? number >= min : number > min)
5661
- && (includeEqualMax ? number <= max : number < max)) {
5662
- return idx;
5663
- }
5664
- }
5665
- return -1;
5666
- }
5667
- static getDefaultColor(size, nullColor) {
5668
- const colorsArray = new Uint8ClampedArray(size);
5669
- for (let i = 0; i < size; i += 4) {
5670
- [colorsArray[i], colorsArray[i + 1], colorsArray[i + 2], colorsArray[i + 3]] = nullColor;
5671
- }
5672
- return colorsArray;
5673
- }
5674
- static getColorFromChromaType(colorDefinition, alpha = 255) {
5675
- if (!Array.isArray(colorDefinition) || colorDefinition.length !== 4) {
5676
- return [...chroma(colorDefinition).rgb(), alpha];
5677
- }
5678
- return colorDefinition;
5679
- }
5680
- static hasPixelsNoData(pixels, noDataValue) {
5681
- return noDataValue !== undefined && pixels.every((pixel) => pixel === noDataValue);
5682
- }
5683
- }
5684
-
5685
5823
  class GeoImage {
5686
5824
  data;
5687
5825
  async setUrl(url) {
@@ -5709,6 +5847,7 @@ class GeoImage {
5709
5847
  let width;
5710
5848
  let height;
5711
5849
  let bounds;
5850
+ let cellSizeMeters;
5712
5851
  if (typeof (input) === 'string') {
5713
5852
  // TODO not tested
5714
5853
  // input is type of object
@@ -5723,9 +5862,10 @@ class GeoImage {
5723
5862
  width = input.width;
5724
5863
  height = input.height;
5725
5864
  bounds = input.bounds;
5865
+ cellSizeMeters = input.cellSizeMeters;
5726
5866
  }
5727
5867
  // Delegate to TerrainGenerator
5728
- return TerrainGenerator.generate({ width, height, rasters, bounds }, options, meshMaxError);
5868
+ return await TerrainGenerator.generate({ width, height, rasters, bounds, cellSizeMeters }, options, meshMaxError);
5729
5869
  }
5730
5870
  async getBitmap(input, options) {
5731
5871
  let rasters = [];
@@ -6017,14 +6157,21 @@ class CogTiles {
6017
6157
  async getTile(x, y, z, bounds, meshMaxError) {
6018
6158
  let requiredSize = this.tileSize; // Default 256 for image/bitmap
6019
6159
  if (this.options.type === 'terrain') {
6020
- requiredSize = this.tileSize + 1; // 257 for stitching
6160
+ const isKernel = this.options.useSlope || this.options.useHillshade;
6161
+ requiredSize = this.tileSize + (isKernel ? 2 : 1); // 258 for kernel (3×3 border), 257 for normal stitching
6021
6162
  }
6022
6163
  const tileData = await this.getTileFromImage(x, y, z, requiredSize);
6164
+ // Compute true ground cell size in meters from tile indices.
6165
+ // Tile y in slippy-map convention → center latitude → Web Mercator distortion correction.
6166
+ const latRad = Math.atan(Math.sinh(Math.PI * (1 - 2 * (y + 0.5) / Math.pow(2, z))));
6167
+ const tileWidthMeters = (EARTH_CIRCUMFERENCE / Math.pow(2, z)) * Math.cos(latRad);
6168
+ const cellSizeMeters = tileWidthMeters / this.tileSize;
6023
6169
  return this.geo.getMap({
6024
6170
  rasters: [tileData[0]],
6025
6171
  width: requiredSize,
6026
6172
  height: requiredSize,
6027
6173
  bounds,
6174
+ cellSizeMeters,
6028
6175
  }, this.options, meshMaxError);
6029
6176
  }
6030
6177
  /**
@@ -6418,6 +6565,12 @@ class CogTerrainLayer extends CompositeLayer {
6418
6565
  // Trigger a refresh of the tiles
6419
6566
  this.state.terrainCogTiles.options.useChannelIndex = null; // Clear cached index
6420
6567
  }
6568
+ // When the external cogTiles instance is swapped (e.g. mode switch), update state so
6569
+ // renderLayers picks up the new reference and the TileLayer updateTrigger fires a refetch
6570
+ // while keeping old tile content visible until new tiles are ready.
6571
+ if (props.cogTiles && props.cogTiles !== oldProps.cogTiles) {
6572
+ this.setState({ terrainCogTiles: props.cogTiles });
6573
+ }
6421
6574
  if (props.workerUrl) {
6422
6575
  log.removed('workerUrl', 'loadOptions.terrain.workerUrl')();
6423
6576
  }
@@ -6475,15 +6628,16 @@ class CogTerrainLayer extends CompositeLayer {
6475
6628
  }
6476
6629
  // const [mesh, texture] = data;
6477
6630
  const [meshResult] = data;
6478
- return new SubLayerClass({ ...props, tileSize: 256 }, {
6631
+ const tileTexture = (!this.props.disableTexture && meshResult?.texture) ? meshResult.texture : null;
6632
+ return new SubLayerClass({ ...props, tileSize: props.tileSize }, {
6479
6633
  data: DUMMY_DATA,
6480
6634
  mesh: meshResult?.map,
6481
- // texture,
6635
+ texture: tileTexture,
6482
6636
  _instanced: false,
6483
6637
  pickable: props.pickable,
6484
6638
  coordinateSystem: COORDINATE_SYSTEM.CARTESIAN,
6485
6639
  // getPosition: (d) => [0, 0, 0],
6486
- getColor: color,
6640
+ getColor: tileTexture ? [255, 255, 255] : color,
6487
6641
  wireframe,
6488
6642
  material,
6489
6643
  });
@@ -6532,6 +6686,9 @@ class CogTerrainLayer extends CompositeLayer {
6532
6686
  // texture: urlTemplateToUpdateTrigger(texture),
6533
6687
  meshMaxError,
6534
6688
  elevationDecoder,
6689
+ // When cogTiles instance is swapped (e.g. mode switch), refetch tiles.
6690
+ // deck.gl keeps old tile content visible until new tiles are ready.
6691
+ terrainCogTiles: this.state.terrainCogTiles,
6535
6692
  },
6536
6693
  },
6537
6694
  onViewportLoad: this.onViewportLoad.bind(this),