@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/README.md +35 -5
- package/dist/cjs/index.js +496 -339
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/index.min.js +2 -2
- package/dist/cjs/index.min.js.map +1 -1
- package/dist/esm/index.js +496 -339
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/index.min.js +2 -2
- package/dist/esm/index.min.js.map +1 -1
- package/dist/esm/types/core/GeoImage.d.ts +2 -0
- package/dist/esm/types/core/lib/BitmapGenerator.d.ts +11 -6
- package/dist/esm/types/core/lib/KernelGenerator.d.ts +31 -0
- package/dist/esm/types/core/lib/TerrainGenerator.d.ts +6 -1
- package/dist/esm/types/core/types.d.ts +27 -19
- package/dist/esm/types/layers/CogTerrainLayer.d.ts +6 -0
- package/package.json +1 -1
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
|
-
|
|
4545
|
-
|
|
4546
|
-
|
|
4547
|
-
|
|
4548
|
-
|
|
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,
|
|
5600
|
+
mesh = this.getMartiniTileMesh(meshMaxError, meshWidth, meshTerrain);
|
|
5237
5601
|
break;
|
|
5238
5602
|
case 'delatin':
|
|
5239
|
-
mesh = this.getDelatinTileMesh(meshMaxError,
|
|
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,
|
|
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,
|
|
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
|
-
|
|
5270
|
-
|
|
5271
|
-
|
|
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:
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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),
|