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