@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/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
|
-
|
|
4547
|
-
|
|
4548
|
-
|
|
4549
|
-
|
|
4550
|
-
|
|
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,
|
|
5602
|
+
mesh = this.getMartiniTileMesh(meshMaxError, meshWidth, meshTerrain);
|
|
5239
5603
|
break;
|
|
5240
5604
|
case 'delatin':
|
|
5241
|
-
mesh = this.getDelatinTileMesh(meshMaxError,
|
|
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,
|
|
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,
|
|
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
|
-
|
|
5272
|
-
|
|
5273
|
-
|
|
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:
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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),
|