ol-stac 1.5.1 → 1.7.0

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/util.js CHANGED
@@ -183,6 +183,73 @@ export async function getStacObjectsForEvent(event, exclude = null, selectedFeat
183
183
  });
184
184
  return [...objects];
185
185
  }
186
+ /**
187
+ * Determines the value range for a visualization stretch from STAC
188
+ * statistics: mean ± 2σ (~95% of the values), clamped to the declared
189
+ * minimum/maximum.
190
+ * @param {Object} stats The statistics (e.g. from stac-js `getStatistics()`).
191
+ * @return {{minimum: (number|undefined), maximum: (number|undefined)}} The stretch range.
192
+ */
193
+ function getStatisticsStretch(stats) {
194
+ let minimum;
195
+ let maximum;
196
+ if (isObject(stats)) {
197
+ ({ minimum, maximum } = stats);
198
+ const { mean, stddev } = stats;
199
+ if (typeof mean === 'number' && typeof stddev === 'number' && stddev > 0) {
200
+ const stretchMin = mean - 2 * stddev;
201
+ const stretchMax = mean + 2 * stddev;
202
+ minimum =
203
+ typeof minimum === 'number'
204
+ ? Math.max(minimum, stretchMin)
205
+ : stretchMin;
206
+ maximum =
207
+ typeof maximum === 'number'
208
+ ? Math.min(maximum, stretchMax)
209
+ : stretchMax;
210
+ }
211
+ }
212
+ return { minimum, maximum };
213
+ }
214
+ /**
215
+ * Determines the value range for a visualization stretch from the STAC
216
+ * statistics of a band or asset, if complete.
217
+ * @param {Asset|Band} source The band or asset to read the statistics from.
218
+ * @return {Array<number>|null} The [min, max] range, or `null`.
219
+ */
220
+ function getStatisticsRange(source) {
221
+ const { minimum, maximum } = getStatisticsStretch(source.getStatistics());
222
+ if (typeof minimum === 'number' && typeof maximum === 'number') {
223
+ return [minimum, maximum];
224
+ }
225
+ return null;
226
+ }
227
+ /**
228
+ * Parses a nodata value from render extension metadata, which may be a
229
+ * number or a string (`nan`, `inf` and `-inf`, as titiler accepts them).
230
+ * @param {*} value The nodata value.
231
+ * @return {number|undefined} The nodata value, or `undefined`.
232
+ */
233
+ function parseNoDataValue(value) {
234
+ if (typeof value === 'number') {
235
+ return value;
236
+ }
237
+ if (typeof value === 'string' && value.trim().length > 0) {
238
+ switch (value.trim().toLowerCase()) {
239
+ case 'nan':
240
+ return NaN;
241
+ case 'inf':
242
+ return Infinity;
243
+ case '-inf':
244
+ return -Infinity;
245
+ default: {
246
+ const parsed = Number(value);
247
+ return isNaN(parsed) ? undefined : parsed;
248
+ }
249
+ }
250
+ }
251
+ return undefined;
252
+ }
186
253
  /**
187
254
  * Get the source info for the GeoTiff from the asset.
188
255
  * @param {import('stac-js').Asset} asset The asset to read the information from.
@@ -205,22 +272,7 @@ export function getGeoTiffSourceInfoFromAsset(asset, selectedBands) {
205
272
  const nodataValues = new Array(bandCount).fill(undefined);
206
273
  let index = 0;
207
274
  for (const source of sources) {
208
- const stats = source.getStatistics();
209
- let { minimum, maximum } = stats;
210
- const { mean, stddev } = stats;
211
- // Use mean ± 2σ for a better visualization stretch (~95% of values)
212
- if (typeof mean === 'number' && typeof stddev === 'number' && stddev > 0) {
213
- const stretchMin = mean - 2 * stddev;
214
- const stretchMax = mean + 2 * stddev;
215
- minimum =
216
- typeof minimum === 'number'
217
- ? Math.max(minimum, stretchMin)
218
- : stretchMin;
219
- maximum =
220
- typeof maximum === 'number'
221
- ? Math.min(maximum, stretchMax)
222
- : stretchMax;
223
- }
275
+ const { minimum, maximum } = getStatisticsStretch(source.getStatistics());
224
276
  if (typeof minimum === 'number') {
225
277
  minValues[index] = minimum;
226
278
  }
@@ -236,16 +288,27 @@ export function getGeoTiffSourceInfoFromAsset(asset, selectedBands) {
236
288
  }
237
289
  index++;
238
290
  }
291
+ const render = getRenderForAsset(asset);
292
+ const usableRender = isUsableRender(render);
293
+ // A usable render defines the complete visualization, so its value range
294
+ // takes precedence over the statistics of the default visualization
295
+ const rescale = usableRender && Array.isArray(render.rescale) ? render.rescale : [];
239
296
  const defined = (v) => v !== undefined;
240
- if (minValues.some(defined)) {
241
- sourceInfo.min = perBand
242
- ? minValues
243
- : Math.min(...minValues.filter(defined));
297
+ if (Array.isArray(rescale[0]) && rescale[0].length >= 2) {
298
+ sourceInfo.min = rescale[0][0];
299
+ sourceInfo.max = rescale[0][1];
244
300
  }
245
- if (maxValues.some(defined)) {
246
- sourceInfo.max = perBand
247
- ? maxValues
248
- : Math.max(...maxValues.filter(defined));
301
+ else {
302
+ if (minValues.some(defined)) {
303
+ sourceInfo.min = perBand
304
+ ? minValues
305
+ : Math.min(...minValues.filter(defined));
306
+ }
307
+ if (maxValues.some(defined)) {
308
+ sourceInfo.max = perBand
309
+ ? maxValues
310
+ : Math.max(...maxValues.filter(defined));
311
+ }
249
312
  }
250
313
  if (nodataValues.some(defined)) {
251
314
  if (perBand) {
@@ -258,6 +321,13 @@ export function getGeoTiffSourceInfoFromAsset(asset, selectedBands) {
258
321
  }
259
322
  }
260
323
  }
324
+ else if (usableRender) {
325
+ // As a last resort, use the nodata value from the render extension
326
+ const nodata = parseNoDataValue(render.nodata);
327
+ if (nodata !== undefined) {
328
+ sourceInfo.nodata = nodata;
329
+ }
330
+ }
261
331
  if (selectedBands.length > 0) {
262
332
  sourceInfo.bands = selectedBands
263
333
  .map((band) => {
@@ -275,13 +345,26 @@ export function getGeoTiffSourceInfoFromAsset(asset, selectedBands) {
275
345
  .filter((band) => band !== null);
276
346
  }
277
347
  else {
278
- const visualBands = asset.findVisualBands();
279
- if (visualBands) {
280
- sourceInfo.bands = [
281
- visualBands.red.getIndex() + 1,
282
- visualBands.green.getIndex() + 1,
283
- visualBands.blue.getIndex() + 1,
284
- ];
348
+ // A usable render defines the complete visualization, including the
349
+ // bands to show (paired with its value ranges)
350
+ if (usableRender && Array.isArray(render.bands)) {
351
+ const indices = render.bands
352
+ .map((name) => asset.findBand(name))
353
+ .filter(isObject)
354
+ .map((band) => band.getIndex() + 1);
355
+ if (indices.length > 0) {
356
+ sourceInfo.bands = indices;
357
+ }
358
+ }
359
+ if (!sourceInfo.bands) {
360
+ const visualBands = asset.findVisualBands();
361
+ if (visualBands) {
362
+ sourceInfo.bands = [
363
+ visualBands.red.getIndex() + 1,
364
+ visualBands.green.getIndex() + 1,
365
+ visualBands.blue.getIndex() + 1,
366
+ ];
367
+ }
285
368
  }
286
369
  }
287
370
  return sourceInfo;
@@ -305,6 +388,13 @@ export function getBoundsStyle(originalStyle, layerGroup) {
305
388
  /**
306
389
  * Parse the GeoZarr source options from an asset.
307
390
  *
391
+ * If the asset (or its containing Item/Collection) describes the store
392
+ * through the datacube extension (`cube:variables` and `cube:dimensions`),
393
+ * the store is treated as an n-dimensional datacube: the data variable and a
394
+ * selection for its non-spatial dimensions are derived from the metadata.
395
+ * Otherwise, each band is expected to be a separate array in the store,
396
+ * addressed by the band names from the STAC `bands` field.
397
+ *
308
398
  * @param {Asset} asset The asset to read the information from.
309
399
  * @param {Array<number|string>} selectedBands The bands to show. One-based index of the band, or the name of the band.
310
400
  * @return {Object} The GeoZarr source options
@@ -314,6 +404,38 @@ export function getGeoZarrSourceOptionsFromAsset(asset, selectedBands) {
314
404
  const options = {
315
405
  url: asset.getAbsoluteUrl(),
316
406
  };
407
+ const cube = getDatacubeRenderingInfo(asset);
408
+ if (cube) {
409
+ options.variable = cube.variable;
410
+ options.dimensions = {};
411
+ if (cube.bandDimension) {
412
+ const indices = getDatacubeBandIndices(cube.bandDimension.values, selectedBands, asset);
413
+ if (indices.length > 0) {
414
+ options.dimensions[cube.bandDimension.name] = indices;
415
+ }
416
+ }
417
+ for (const dimension of cube.extraDimensions) {
418
+ options.dimensions[dimension.name] = dimension.defaultIndex;
419
+ }
420
+ if (cube.extent) {
421
+ options.extent = cube.extent;
422
+ }
423
+ const projBBox = asset.getMetadata('proj:bbox');
424
+ if (Array.isArray(projBBox) && projBBox.length >= 4) {
425
+ // proj:bbox may be 3D (xmin, ymin, zmin, xmax, ymax, zmax)
426
+ options.extent =
427
+ projBBox.length >= 6
428
+ ? [projBBox[0], projBBox[1], projBBox[3], projBBox[4]]
429
+ : projBBox.slice(0, 4);
430
+ }
431
+ const projTransform = asset.getMetadata('proj:transform');
432
+ if (Array.isArray(projTransform) &&
433
+ projTransform.length >= 6 &&
434
+ projTransform[4] > 0) {
435
+ options.flipY = true;
436
+ }
437
+ return options;
438
+ }
317
439
  if (selectedBands.length > 0) {
318
440
  options.bands = selectedBands
319
441
  .map((band) => {
@@ -325,24 +447,581 @@ export function getGeoZarrSourceOptionsFromAsset(asset, selectedBands) {
325
447
  if (isObject(bandObj) && typeof bandObj.name === 'string') {
326
448
  return bandObj.name;
327
449
  }
328
- // eslint-disable-next-line no-console
329
- console.error(`Band with index ${band} not found in asset ${asset.getKey()}`);
330
450
  return null;
331
451
  })
332
452
  .filter(Boolean);
333
453
  }
334
454
  else {
335
- const bands = asset.findVisualBands();
336
- if (bands) {
337
- options.bands = [
338
- bands.red.name,
339
- bands.green.name,
340
- bands.blue.name,
341
- ].filter(Boolean);
455
+ // A usable render defines the complete visualization, including the
456
+ // bands to show (paired with its value ranges)
457
+ const render = getRenderForAsset(asset);
458
+ if (isUsableRender(render) &&
459
+ Array.isArray(render.bands) &&
460
+ render.bands.length > 0) {
461
+ options.bands = render.bands;
462
+ }
463
+ else {
464
+ const bands = asset.findVisualBands();
465
+ if (bands) {
466
+ options.bands = [
467
+ bands.red.name,
468
+ bands.green.name,
469
+ bands.blue.name,
470
+ ].filter(Boolean);
471
+ }
342
472
  }
343
473
  }
474
+ if (!Array.isArray(options.bands)) {
475
+ options.bands = [];
476
+ }
344
477
  return options;
345
478
  }
479
+ /**
480
+ * The default maximum number of pixels of the coarsest resolution level for
481
+ * an asset to be shown. Showing the full extent of an asset loads every
482
+ * tile of the coarsest level, so files without (sufficient) overviews
483
+ * would trigger excessive tile loads.
484
+ * @type {number}
485
+ */
486
+ export const MAX_DEFAULT_DISPLAY_PIXELS = 16 * 1024 * 1024;
487
+ /**
488
+ * Returns the number of pixels of the coarsest resolution level of a
489
+ * configured tile source (e.g. GeoTIFF or GeoZarr). This is the amount of
490
+ * data that displaying the full extent of the asset requires to load.
491
+ * @param {import('ol/source/Tile.js').default} source The configured (ready) source.
492
+ * @return {number} The number of pixels, or `Infinity` if unknown.
493
+ * @api
494
+ */
495
+ export function getDisplayPixels(source) {
496
+ const tileGrid = source.getTileGrid();
497
+ if (!tileGrid) {
498
+ return Infinity;
499
+ }
500
+ const extent = tileGrid.getExtent();
501
+ const resolution = tileGrid.getResolutions()[0];
502
+ if (!extent || !(resolution > 0)) {
503
+ return Infinity;
504
+ }
505
+ let rowResolution = resolution;
506
+ // GeoZarr pixels are not necessarily square (e.g. a square array covering
507
+ // the full EPSG:4326 world); use the row resolution the source tracks for
508
+ // the coarsest level, if available
509
+ const zarrSource = /** @type {*} */ (source);
510
+ const wmtsTileGrid = /** @type {*} */ (tileGrid);
511
+ if (zarrSource.levelRowInfo_ &&
512
+ typeof wmtsTileGrid.getMatrixIds === 'function') {
513
+ const rowInfo = zarrSource.levelRowInfo_[wmtsTileGrid.getMatrixIds()[0]];
514
+ if (rowInfo && rowInfo.rowResolution > 0) {
515
+ rowResolution = rowInfo.rowResolution;
516
+ }
517
+ }
518
+ return (((extent[2] - extent[0]) / resolution) *
519
+ ((extent[3] - extent[1]) / rowResolution));
520
+ }
521
+ /**
522
+ * Checks whether displaying the full extent of a configured tile source at
523
+ * the coarsest available resolution requires excessive tile loads.
524
+ * @param {import('ol/source/Tile.js').default} source The configured (ready) source.
525
+ * @param {number} [maxPixels] The pixel limit for the coarsest resolution level.
526
+ * @return {boolean} `true` if the source exceeds the limit.
527
+ * @api
528
+ */
529
+ export function exceedsDisplayLimit(source, maxPixels = MAX_DEFAULT_DISPLAY_PIXELS) {
530
+ return getDisplayPixels(source) > maxPixels;
531
+ }
532
+ /**
533
+ * Returns the render (from the render extension's `renders` field) that
534
+ * applies to the given asset: the first render that lists the asset's key
535
+ * in its `assets` field, or the first render without an `assets` field.
536
+ *
537
+ * @param {Asset} asset The asset to find the render for.
538
+ * @return {Object|null} The render object, or `null`.
539
+ * @api
540
+ */
541
+ export function getRenderForAsset(asset) {
542
+ const renders = asset.getMetadata('renders');
543
+ if (!isObject(renders)) {
544
+ return null;
545
+ }
546
+ const key = asset.getKey();
547
+ let fallback = null;
548
+ for (const name in renders) {
549
+ const render = renders[name];
550
+ if (!isObject(render)) {
551
+ continue;
552
+ }
553
+ if (Array.isArray(render.assets)) {
554
+ if (render.assets.includes(key)) {
555
+ return render;
556
+ }
557
+ }
558
+ else if (!fallback) {
559
+ fallback = render;
560
+ }
561
+ }
562
+ return fallback;
563
+ }
564
+ /**
565
+ * Checks whether a render (from the render extension) can be implemented:
566
+ * a render defines a complete visualization, so it is only used when it
567
+ * defines at least one aspect that is supported (`bands`, `rescale`,
568
+ * `colormap`, `nodata`) and none that can't be implemented reliably
569
+ * (band math expressions, color formulas, and named colormaps, which are
570
+ * not interoperable). Otherwise the default visualization derived from the
571
+ * general STAC metadata applies.
572
+ * @param {Object|null} render The render object.
573
+ * @return {boolean} `true` if the render can be used.
574
+ */
575
+ function isUsableRender(render) {
576
+ if (!isObject(render)) {
577
+ return false;
578
+ }
579
+ if (typeof render.expression === 'string' ||
580
+ typeof render['color_formula'] === 'string' ||
581
+ typeof render['colormap_name'] === 'string') {
582
+ return false;
583
+ }
584
+ return Boolean((Array.isArray(render.bands) && render.bands.length > 0) ||
585
+ (Array.isArray(render.rescale) && render.rescale.length > 0) ||
586
+ isObject(render['colormap']) ||
587
+ Array.isArray(render['colormap']) ||
588
+ render.nodata !== undefined);
589
+ }
590
+ /**
591
+ * Checks whether a band or asset declares a floating point data type.
592
+ * Floating point data can be assumed to be continuous (not categorical).
593
+ * @param {Asset|Band} source The band or asset to read the data type from.
594
+ * @return {boolean} `true` if the data type is declared as floating point.
595
+ */
596
+ function isFloatingPoint(source) {
597
+ const type = source.getMetadata('data_type');
598
+ return typeof type === 'string' && type.includes('float');
599
+ }
600
+ /**
601
+ * Creates a linear stretch expression that maps the given value range of a
602
+ * band to 0-255.
603
+ * @param {number} band The one-based rendered band.
604
+ * @param {Array<number>} range The [min, max] value range.
605
+ * @return {Array<*>} The expression.
606
+ */
607
+ function stretch(band, range) {
608
+ return [
609
+ 'interpolate',
610
+ ['linear'],
611
+ ['band', band],
612
+ range[0],
613
+ 0,
614
+ range[1],
615
+ 255,
616
+ ];
617
+ }
618
+ /**
619
+ * Creates a WebGLTileLayer style that colors the given value range of a
620
+ * band with the given colors, evenly distributed over the range.
621
+ * @param {Array<import('ol/color.js').Color|string>} colors The colors.
622
+ * @param {number} band The one-based rendered band.
623
+ * @param {Array<number>} range The [min, max] value range.
624
+ * @return {Object} A WebGLTileLayer style.
625
+ */
626
+ function createGradientStyle(colors, band, range) {
627
+ /** @type {Array<*>} */
628
+ const color = ['interpolate', ['linear'], ['band', band]];
629
+ const last = colors.length - 1;
630
+ colors.forEach((stop, i) => {
631
+ color.push(range[0] + (i / last) * (range[1] - range[0]), stop);
632
+ });
633
+ return { color };
634
+ }
635
+ /**
636
+ * Creates a WebGLTileLayer color expression from a render extension
637
+ * `colormap`, following the rio-tiler semantics that the render extension
638
+ * references (`colormap_name` is not supported, as the available names are
639
+ * not standardized):
640
+ * - An array of intervals `[[[min, max], color], ...]` colors the raw data
641
+ * values with `min <= value < max`; later intervals take precedence.
642
+ * - An object with exactly the integer keys 0-255 is a lookup table for the
643
+ * data after rescaling it to 0-255 (truncated, as rio-tiler casts to
644
+ * uint8).
645
+ * - Any other object colors the (rescaled) data values that exactly match a
646
+ * key.
647
+ * Unmatched values are transparent. Colors are `[r, g, b]` or
648
+ * `[r, g, b, a]` arrays (alpha in 0-255, as in rio-tiler).
649
+ * @param {Object|Array} colormap The colormap.
650
+ * @param {Array<number>|null} range The range to rescale the data to 0-255, if any.
651
+ * @return {Object|null} A WebGLTileLayer style, or `null`.
652
+ */
653
+ function createColormapStyle(colormap, range) {
654
+ if (Array.isArray(colormap)) {
655
+ // rio-tiler applies intervals sequentially so that later intervals
656
+ // overwrite earlier ones; a case expression picks the first match
657
+ const cases = [];
658
+ for (const entry of colormap.slice().reverse()) {
659
+ if (!Array.isArray(entry) || !Array.isArray(entry[0])) {
660
+ continue;
661
+ }
662
+ cases.push([
663
+ 'all',
664
+ ['>=', ['band', 1], entry[0][0]],
665
+ ['<', ['band', 1], entry[0][1]],
666
+ ]);
667
+ cases.push(toColor(entry[1]));
668
+ }
669
+ if (cases.length > 0) {
670
+ return { color: ['case', ...cases, [0, 0, 0, 0]] };
671
+ }
672
+ return null;
673
+ }
674
+ if (!isObject(colormap)) {
675
+ return null;
676
+ }
677
+ const keys = Object.keys(colormap).filter((key) => !isNaN(Number(key)));
678
+ if (keys.length === 0) {
679
+ return null;
680
+ }
681
+ // Rescale the data to 0-255 and truncate, as rio-tiler does before
682
+ // applying a colormap
683
+ /** @type {Array<*>} */
684
+ let value = ['band', 1];
685
+ if (Array.isArray(range) && range[0] < range[1]) {
686
+ value = ['floor', ['clamp', stretch(1, range), 0, 255]];
687
+ }
688
+ const isLut = keys.length === 256 &&
689
+ keys.every((key) => {
690
+ const index = Number(key);
691
+ return Number.isInteger(index) && index >= 0 && index < 256;
692
+ });
693
+ if (isLut) {
694
+ const palette = new Array(256);
695
+ for (const key of keys) {
696
+ palette[Number(key)] = toColor(colormap[key]);
697
+ }
698
+ return { color: ['palette', value, palette] };
699
+ }
700
+ const cases = [];
701
+ for (const key of keys) {
702
+ cases.push(['==', value, Number(key)], toColor(colormap[key]));
703
+ }
704
+ return { color: ['case', ...cases, [0, 0, 0, 0]] };
705
+ }
706
+ /**
707
+ * Creates a WebGLTileLayer style for a GeoZarr layer from the STAC metadata.
708
+ *
709
+ * A usable render from the render extension (see {@link isUsableRender})
710
+ * defines the complete visualization: the value range comes from its
711
+ * `rescale` and single-band colors from its `colormap` (see
712
+ * {@link createColormapStyle} for the supported forms).
713
+ *
714
+ * Without a usable render, a default visualization is derived from the
715
+ * general STAC metadata: the value range to stretch comes from the
716
+ * `statistics` of the rendered bands (or the asset), the classification
717
+ * extension (`classification:classes`) provides the coloring for
718
+ * single-band categorical data (not applied to floating point data, which
719
+ * is assumed to be continuous), and continuous data is colored with the
720
+ * given default colormap or stretched to grayscale (consistent with
721
+ * single-band COGs).
722
+ *
723
+ * @param {Asset} asset The asset to read the metadata from.
724
+ * @param {Object} sourceOptions The GeoZarr source options (to determine the rendered bands).
725
+ * @param {Array<import('ol/color.js').Color|string>|null} [defaultColormap] The colors
726
+ * of the colormap for continuous single-band data, evenly distributed over the value range.
727
+ * @return {Object|null} A WebGLTileLayer style, or `null` if the metadata provides none.
728
+ * @api
729
+ */
730
+ export function getGeoZarrStyleFromAsset(asset, sourceOptions, defaultColormap = null) {
731
+ let bandCount = 1;
732
+ /** @type {Array<string>} */
733
+ let bandNames = [];
734
+ if (sourceOptions.variable) {
735
+ if (isObject(sourceOptions.dimensions)) {
736
+ for (const key in sourceOptions.dimensions) {
737
+ if (Array.isArray(sourceOptions.dimensions[key])) {
738
+ bandCount = sourceOptions.dimensions[key].length;
739
+ }
740
+ }
741
+ }
742
+ }
743
+ else if (Array.isArray(sourceOptions.bands)) {
744
+ bandCount = sourceOptions.bands.length || 1;
745
+ bandNames = sourceOptions.bands.filter((band) => typeof band === 'string');
746
+ }
747
+ // The band (or the asset) that describes the rendered band
748
+ const bandSource = (index) => {
749
+ if (bandNames.length > index) {
750
+ const band = asset.findBand(bandNames[index]);
751
+ if (band) {
752
+ return band;
753
+ }
754
+ }
755
+ return asset;
756
+ };
757
+ const render = getRenderForAsset(asset);
758
+ const rescale = isUsableRender(render) && Array.isArray(render.rescale)
759
+ ? render.rescale
760
+ : [];
761
+ // A usable render that defines any styling aspect defines the complete
762
+ // visualization; a render that only selects bands (or sets nodata) leaves
763
+ // the styling to the default visualization
764
+ const renderStyles = rescale.length > 0 || (isUsableRender(render) && render['colormap']);
765
+ // The value range to stretch per rendered band: only the render's rescale
766
+ // when the render styles the data, otherwise the STAC statistics of the
767
+ // rendered band (or the asset)
768
+ const rangeFor = (index) => {
769
+ if (renderStyles) {
770
+ if (Array.isArray(rescale[index]) && rescale[index].length >= 2) {
771
+ return rescale[index];
772
+ }
773
+ if (Array.isArray(rescale[0]) && rescale[0].length >= 2) {
774
+ return rescale[0];
775
+ }
776
+ return null;
777
+ }
778
+ return getStatisticsRange(bandSource(index)) || getStatisticsRange(asset);
779
+ };
780
+ if (bandCount >= 3) {
781
+ const ranges = [rangeFor(0), rangeFor(1), rangeFor(2)];
782
+ if (!ranges[0]) {
783
+ return null;
784
+ }
785
+ return {
786
+ color: [
787
+ 'color',
788
+ stretch(1, ranges[0]),
789
+ stretch(2, ranges[1] || ranges[0]),
790
+ stretch(3, ranges[2] || ranges[0]),
791
+ ],
792
+ };
793
+ }
794
+ // Single band
795
+ if (renderStyles) {
796
+ if (render['colormap']) {
797
+ const style = createColormapStyle(render['colormap'], rangeFor(0));
798
+ if (style) {
799
+ return style;
800
+ }
801
+ }
802
+ // A rescale without a colormap is a grayscale stretch (as in rio-tiler)
803
+ const range = rangeFor(0);
804
+ if (range) {
805
+ const gray = stretch(1, range);
806
+ return { color: ['color', gray, gray, gray] };
807
+ }
808
+ return null;
809
+ }
810
+ // Default visualization: the classification extension for categorical
811
+ // data (floating point data is assumed to be continuous)
812
+ if (!isFloatingPoint(bandSource(0))) {
813
+ let lookupBands;
814
+ if (bandNames.length === 1) {
815
+ const band = asset.findBand(bandNames[0]);
816
+ if (band) {
817
+ lookupBands = [band.getIndex() + 1];
818
+ }
819
+ }
820
+ const classification = getClassificationStyle(asset, lookupBands, 1);
821
+ if (classification) {
822
+ return classification;
823
+ }
824
+ }
825
+ // Continuous data is colored with the default colormap or stretched to
826
+ // grayscale (consistent with single-band COGs)
827
+ const range = rangeFor(0);
828
+ if (range) {
829
+ if (Array.isArray(defaultColormap) && defaultColormap.length >= 2) {
830
+ return createGradientStyle(defaultColormap, 1, range);
831
+ }
832
+ const gray = stretch(1, range);
833
+ return { color: ['color', gray, gray, gray] };
834
+ }
835
+ return null;
836
+ }
837
+ /**
838
+ * Normalizes a color from render extension metadata (alpha in 0-255, as in
839
+ * rio-tiler) to an OpenLayers color (alpha in 0-1).
840
+ * @param {Array<number>|string} color The color to normalize.
841
+ * @return {Array<number>|string} The OpenLayers color.
842
+ */
843
+ function toColor(color) {
844
+ if (Array.isArray(color) && color.length === 4) {
845
+ return [color[0], color[1], color[2], color[3] / 255];
846
+ }
847
+ return color;
848
+ }
849
+ /**
850
+ * Information for rendering a datacube asset.
851
+ *
852
+ * @typedef {Object} DatacubeRenderingInfo
853
+ * @property {string} variable The name of the data variable to render.
854
+ * @property {{name: string, values: Array<string>}|null} bandDimension The
855
+ * bands dimension of the variable with its ordered values, if any.
856
+ * @property {Array<{name: string, defaultIndex: number}>} extraDimensions All
857
+ * other non-spatial dimensions of the variable with the index to show by
858
+ * default (the most recent value for temporal dimensions, otherwise 0).
859
+ * @property {Array<number>|null} extent The extent of the spatial dimensions
860
+ * (in their reference system), if declared.
861
+ */
862
+ /**
863
+ * Reads the datacube extension metadata (`cube:variables` and
864
+ * `cube:dimensions`, inherited from the containing Item/Collection if not
865
+ * present on the asset) and determines the data variable to render and how
866
+ * its non-spatial dimensions should be sliced.
867
+ *
868
+ * @param {Asset} asset The asset to read the information from.
869
+ * @return {DatacubeRenderingInfo|null} The rendering info, or `null` if the
870
+ * asset is not described as a datacube.
871
+ */
872
+ function getDatacubeRenderingInfo(asset) {
873
+ const dimensions = asset.getMetadata('cube:dimensions');
874
+ const variables = asset.getMetadata('cube:variables');
875
+ if (!isObject(dimensions) || !isObject(variables)) {
876
+ return null;
877
+ }
878
+ const spatialDims = [];
879
+ const spatialExtents = { x: null, y: null };
880
+ let bandDimension = null;
881
+ for (const name in dimensions) {
882
+ const dimension = dimensions[name];
883
+ if (!isObject(dimension)) {
884
+ continue;
885
+ }
886
+ if (dimension.type === 'spatial') {
887
+ spatialDims.push(name);
888
+ if ((dimension.axis === 'x' || dimension.axis === 'y') &&
889
+ Array.isArray(dimension.extent) &&
890
+ dimension.extent.length === 2) {
891
+ spatialExtents[dimension.axis] = dimension.extent;
892
+ }
893
+ }
894
+ else if (dimension.type === 'bands') {
895
+ bandDimension = {
896
+ name,
897
+ values: Array.isArray(dimension.values) ? dimension.values : [],
898
+ };
899
+ }
900
+ }
901
+ if (spatialDims.length < 2) {
902
+ return null;
903
+ }
904
+ // Find the data variable that covers the spatial dimensions. When the
905
+ // asset declares STAC `bands`, each band is expected to be its own array
906
+ // in the store (e.g. EOPF), which the `bands` mode handles instead —
907
+ // unless a variable packs the bands dimension into a single array.
908
+ const hasStacBands = asset.getBands().length > 0;
909
+ let variable = null;
910
+ let variableDims = null;
911
+ for (const name in variables) {
912
+ const v = variables[name];
913
+ if (!isObject(v) || !Array.isArray(v.dimensions)) {
914
+ continue;
915
+ }
916
+ if (typeof v.type === 'string' && v.type !== 'data') {
917
+ continue;
918
+ }
919
+ if (!spatialDims.every((dim) => v.dimensions.includes(dim))) {
920
+ continue;
921
+ }
922
+ if (hasStacBands &&
923
+ !(bandDimension && v.dimensions.includes(bandDimension.name))) {
924
+ // With STAC bands declared, each band is expected to be its own array
925
+ // (addressed through the `bands` mode), unless the variable packs the
926
+ // declared bands dimension
927
+ continue;
928
+ }
929
+ // Prefer a variable that includes the bands dimension
930
+ if (bandDimension && v.dimensions.includes(bandDimension.name)) {
931
+ variable = name;
932
+ variableDims = v.dimensions;
933
+ break;
934
+ }
935
+ if (!variable) {
936
+ variable = name;
937
+ variableDims = v.dimensions;
938
+ }
939
+ }
940
+ if (!variable) {
941
+ return null;
942
+ }
943
+ if (bandDimension && !variableDims.includes(bandDimension.name)) {
944
+ bandDimension = null;
945
+ }
946
+ const extraDimensions = [];
947
+ for (const name of variableDims) {
948
+ if (spatialDims.includes(name) ||
949
+ (bandDimension && name === bandDimension.name)) {
950
+ continue;
951
+ }
952
+ const dimension = dimensions[name];
953
+ let defaultIndex = 0;
954
+ if (isObject(dimension) &&
955
+ dimension.type === 'temporal' &&
956
+ Array.isArray(dimension.values) &&
957
+ dimension.values.length > 0) {
958
+ // Show the most recent time step by default. Timestamps are compared
959
+ // as instants, as string comparison breaks with mixed UTC offsets.
960
+ defaultIndex = dimension.values.reduce((latest, value, index, values) => Date.parse(value) > Date.parse(values[latest]) ? index : latest, 0);
961
+ }
962
+ extraDimensions.push({ name, defaultIndex });
963
+ }
964
+ let extent = null;
965
+ if (spatialExtents.x && spatialExtents.y) {
966
+ extent = [
967
+ spatialExtents.x[0],
968
+ spatialExtents.y[0],
969
+ spatialExtents.x[1],
970
+ spatialExtents.y[1],
971
+ ];
972
+ }
973
+ return { variable, bandDimension, extraDimensions, extent };
974
+ }
975
+ /**
976
+ * Determines the (0-based) indices into the bands dimension of a datacube
977
+ * to render.
978
+ *
979
+ * @param {Array<string>} values The ordered values of the bands dimension.
980
+ * @param {Array<number|string>} selectedBands The bands to show. One-based index of the band, or the name of the band.
981
+ * @param {Asset} asset The asset, for finding the RGB bands.
982
+ * @return {Array<number>} The band indices.
983
+ */
984
+ function getDatacubeBandIndices(values, selectedBands, asset) {
985
+ const isValid = (index) => index >= 0 && (values.length === 0 || index < values.length);
986
+ if (selectedBands.length > 0) {
987
+ return selectedBands
988
+ .map((band) => {
989
+ const index = typeof band === 'number' ? band - 1 : values.indexOf(band);
990
+ if (!isValid(index)) {
991
+ // eslint-disable-next-line no-console
992
+ console.error(`Band ${band} not found in asset ${asset.getKey()}`);
993
+ return -1;
994
+ }
995
+ return index;
996
+ })
997
+ .filter((index) => index >= 0);
998
+ }
999
+ // A usable render defines the complete visualization, including the bands
1000
+ // to show (paired with its value ranges)
1001
+ const render = getRenderForAsset(asset);
1002
+ if (isUsableRender(render) &&
1003
+ Array.isArray(render.bands) &&
1004
+ render.bands.length > 0) {
1005
+ const indices = render.bands
1006
+ .map((name) => values.indexOf(name))
1007
+ .filter((index) => index >= 0);
1008
+ if (indices.length > 0) {
1009
+ return indices;
1010
+ }
1011
+ }
1012
+ // Otherwise, prefer the RGB bands if they can be identified
1013
+ const visual = asset.findVisualBands();
1014
+ if (visual) {
1015
+ const indices = [visual.red.name, visual.green.name, visual.blue.name]
1016
+ .map((name) => values.indexOf(name))
1017
+ .filter((index) => index >= 0);
1018
+ if (indices.length === 3) {
1019
+ return indices;
1020
+ }
1021
+ }
1022
+ // Default to the first (up to) three bands, shown as RGB
1023
+ return values.slice(0, 3).map((_, index) => index);
1024
+ }
346
1025
  /**
347
1026
  * Get a URL from a web-map-link that is specific enough, i.e.
348
1027
  * replaces any occurances of {s} if possible, otherwise returns null.
@@ -363,6 +1042,50 @@ export function getSpecificWebMapUrl(link) {
363
1042
  }
364
1043
  return url;
365
1044
  }
1045
+ /**
1046
+ * Returns all potential web map links for the given STAC entity,
1047
+ * based on the given value for `displayWebMapLink`.
1048
+ * See the STACLayer option of the same name for details.
1049
+ * @param {import('stac-js').STACObject} data The STAC entity to get the links from.
1050
+ * @param {string|boolean|Array<import('./layer/STAC.js').Link|string>} [displayWebMapLink] The value of the `displayWebMapLink` option of the STACLayer.
1051
+ * @return {Array<import('./layer/STAC.js').Link>} An array of links.
1052
+ * @api
1053
+ */
1054
+ export function getWebMapLinks(data, displayWebMapLink = true) {
1055
+ if (displayWebMapLink === false) {
1056
+ return [];
1057
+ }
1058
+ if (!data || data.isAsset) {
1059
+ return [];
1060
+ }
1061
+ let types = ['xyz', 'tilejson', 'pmtiles', 'wmts', 'wms']; // This also defines the priority
1062
+ if (typeof displayWebMapLink === 'string') {
1063
+ types = [displayWebMapLink];
1064
+ }
1065
+ let mapLinks = data.getLinksWithRels(types);
1066
+ if (Array.isArray(displayWebMapLink)) {
1067
+ mapLinks = displayWebMapLink
1068
+ .map((link) => {
1069
+ if (typeof link === 'string') {
1070
+ const match = mapLinks.find((candidate) => candidate.id === link);
1071
+ if (match) {
1072
+ return match;
1073
+ }
1074
+ return null;
1075
+ }
1076
+ return link;
1077
+ })
1078
+ .filter((link) => !!link);
1079
+ }
1080
+ else {
1081
+ mapLinks.sort((a, b) => {
1082
+ const prioA = types.indexOf(a.rel);
1083
+ const prioB = types.indexOf(b.rel);
1084
+ return prioA - prioB;
1085
+ });
1086
+ }
1087
+ return mapLinks;
1088
+ }
366
1089
  /**
367
1090
  * Checks whether the given value is a scalar (string, number, boolean).
368
1091
  * @param {*} value The value to check
@@ -404,6 +1127,10 @@ export function getClassificationClasses(asset, bands) {
404
1127
  classes = bandObj['classification:classes'];
405
1128
  }
406
1129
  }
1130
+ else if ((!bands || bands.length === 0) && assetBands.length === 1) {
1131
+ // A single-band asset without an explicit selection
1132
+ classes = assetBands[0]['classification:classes'];
1133
+ }
407
1134
  // Fall back to asset-level classification or single band
408
1135
  if (!Array.isArray(classes)) {
409
1136
  classes = asset.getMetadata('classification:classes');
@@ -421,10 +1148,12 @@ export function getClassificationClasses(asset, bands) {
421
1148
  *
422
1149
  * @param {import('stac-js').Asset} asset The STAC asset
423
1150
  * @param {Array<number>} [bands] The selected bands (one-based)
1151
+ * @param {number} [styleBand] The one-based band to style, if it differs
1152
+ * from the selected band (e.g. when the source only loads the selected band)
424
1153
  * @return {Object|null} A WebGL tile layer style object, or null
425
1154
  * @api
426
1155
  */
427
- export function getClassificationStyle(asset, bands) {
1156
+ export function getClassificationStyle(asset, bands, styleBand = null) {
428
1157
  const classes = getClassificationClasses(asset, bands);
429
1158
  if (!classes) {
430
1159
  return null;
@@ -437,9 +1166,12 @@ export function getClassificationStyle(asset, bands) {
437
1166
  return null;
438
1167
  }
439
1168
  // Build the match expression: ['match', ['band', n], value, color, ..., fallback]
440
- let band = 1;
441
- if (bands && bands.length === 1) {
442
- band = bands[0];
1169
+ let band = styleBand;
1170
+ if (!band) {
1171
+ band = 1;
1172
+ if (bands && bands.length === 1) {
1173
+ band = bands[0];
1174
+ }
443
1175
  }
444
1176
  const matchExpr = ['match', ['band', band]];
445
1177
  for (const cls of classesWithColor) {
@@ -451,4 +1183,80 @@ export function getClassificationStyle(asset, bands) {
451
1183
  matchExpr.push(['color', 0, 0, 0, 0]);
452
1184
  return { color: matchExpr };
453
1185
  }
1186
+ /**
1187
+ * Creates a WebGLTileLayer style for a GeoTIFF layer from the STAC metadata.
1188
+ *
1189
+ * A usable render from the render extension (see {@link isUsableRender})
1190
+ * defines the complete visualization: for single-band data its `colormap`
1191
+ * provides the colors (see {@link createColormapStyle} for the supported
1192
+ * forms), applied over the range from its `rescale`.
1193
+ *
1194
+ * Without a usable render, the classification extension
1195
+ * (`classification:classes`) provides the coloring for single-band
1196
+ * categorical data (not applied to floating point data, which is assumed
1197
+ * to be continuous), and continuous single-band data is colored with the
1198
+ * given default colormap (over the value range from the statistics).
1199
+ *
1200
+ * When no style is returned, the value ranges from
1201
+ * {@link getGeoTiffSourceInfoFromAsset} stretch the data (to grayscale for
1202
+ * a single band) instead. The returned styles operate on the raw data
1203
+ * values, i.e. the GeoTIFF source must be configured with
1204
+ * `normalize: false`.
1205
+ *
1206
+ * @param {import('stac-js').Asset} asset The STAC asset
1207
+ * @param {import('ol/source/GeoTIFF.js').SourceInfo} sourceInfo The source info
1208
+ * (for the selected bands).
1209
+ * @param {Array<import('ol/color.js').Color|string>|null} [defaultColormap] The colors
1210
+ * of the colormap for continuous single-band data, evenly distributed over the value range.
1211
+ * @return {Object|null} A WebGL tile layer style object, or null
1212
+ * @api
1213
+ */
1214
+ export function getGeoTiffStyleFromAsset(asset, sourceInfo, defaultColormap = null) {
1215
+ const selected = Array.isArray(sourceInfo.bands) ? sourceInfo.bands : [];
1216
+ const bandCount = selected.length > 0
1217
+ ? selected.length
1218
+ : Math.max(asset.getBands().length, 1);
1219
+ const render = getRenderForAsset(asset);
1220
+ if (isUsableRender(render)) {
1221
+ if (bandCount === 1 && render['colormap']) {
1222
+ const rescale = Array.isArray(render.rescale) ? render.rescale : [];
1223
+ const range = Array.isArray(rescale[0]) && rescale[0].length >= 2 ? rescale[0] : null;
1224
+ return createColormapStyle(render['colormap'], range);
1225
+ }
1226
+ // The value range from the render's rescale is applied through the
1227
+ // source info instead
1228
+ return null;
1229
+ }
1230
+ if (bandCount !== 1) {
1231
+ return null;
1232
+ }
1233
+ // Default visualization: the classification extension for categorical
1234
+ // data (floating point data is assumed to be continuous)
1235
+ const bands = asset.getBands();
1236
+ let bandSource = asset;
1237
+ if (selected.length === 1 && bands.length > 0) {
1238
+ bandSource = bands[selected[0] - 1] || asset;
1239
+ }
1240
+ else if (selected.length === 0 && bands.length === 1) {
1241
+ bandSource = bands[0];
1242
+ }
1243
+ if (!isFloatingPoint(bandSource)) {
1244
+ // The source only loads the selected bands, so a single selected band
1245
+ // is rendered as band 1
1246
+ const classification = getClassificationStyle(asset, selected, selected.length === 1 ? 1 : null);
1247
+ if (classification) {
1248
+ return classification;
1249
+ }
1250
+ }
1251
+ // Continuous data is colored with the default colormap, if given
1252
+ if (Array.isArray(defaultColormap) && defaultColormap.length >= 2) {
1253
+ const range = getStatisticsRange(bandSource) ||
1254
+ (bandSource !== asset ? getStatisticsRange(asset) : null);
1255
+ if (range) {
1256
+ return createGradientStyle(defaultColormap, 1, range);
1257
+ }
1258
+ }
1259
+ // Otherwise it is stretched to grayscale through the source info
1260
+ return null;
1261
+ }
454
1262
  //# sourceMappingURL=util.js.map