ol-stac 1.5.0 → 1.6.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
@@ -2,6 +2,7 @@
2
2
  * @module ol/util
3
3
  */
4
4
  import VectorLayer from 'ol/layer/Vector.js';
5
+ import { transformExtent } from 'ol/proj.js';
5
6
  import Circle from 'ol/style/Circle.js';
6
7
  import Fill from 'ol/style/Fill.js';
7
8
  import Stroke from 'ol/style/Stroke.js';
@@ -33,6 +34,53 @@ import { isObject } from 'stac-js/src/utils.js';
33
34
  * @type {string}
34
35
  */
35
36
  export const LABEL_EXTENSION = 'https://stac-extensions.github.io/label/v1.*/schema.json';
37
+ /**
38
+ * Makes a bounding box continuous for use as an (OpenLayers) extent.
39
+ *
40
+ * Bounding boxes that cross the antimeridian have a western longitude that is
41
+ * larger than the eastern longitude (as defined by RFC 7946, section 5.2).
42
+ * For those, the eastern longitude is shifted by +360 so that the extent is
43
+ * continuous across the antimeridian (i.e. `minX <= maxX`).
44
+ *
45
+ * Accepts both 2D (four values) and 3D (six values) bounding boxes and always
46
+ * returns a 2D extent (four values).
47
+ *
48
+ * @param {Array<number>} bbox The bounding box in lon/lat degrees.
49
+ * @return {Array<number>} The continuous 2D bounding box.
50
+ * @api
51
+ */
52
+ export function toContinuousBBox(bbox) {
53
+ // STAC bounding boxes may contain a third dimension, i.e. six values
54
+ // (west, south, minZ, east, north, maxZ). Extract the horizontal 2D extent.
55
+ const hasZ = bbox.length >= 6;
56
+ const west = bbox[0];
57
+ const south = bbox[1];
58
+ const east = bbox[hasZ ? 3 : 2];
59
+ const north = bbox[hasZ ? 4 : 3];
60
+ if (west > east) {
61
+ return [west, south, east + 360, north];
62
+ }
63
+ return [west, south, east, north];
64
+ }
65
+ /**
66
+ * Converts a lon/lat (EPSG:4326) bounding box into a continuous OpenLayers
67
+ * extent in the given projection.
68
+ *
69
+ * Handles antimeridian-crossing bounding boxes (west > east), see
70
+ * {@link toContinuousBBox}.
71
+ *
72
+ * When fitting an antimeridian-crossing extent, configure the OpenLayers
73
+ * `View` with `multiWorld: true`; otherwise the default world constraint may
74
+ * clamp the fitted view and clip the wrapped portion.
75
+ *
76
+ * @param {Array<number>} bbox The bounding box in lon/lat degrees (EPSG:4326).
77
+ * @param {import("ol/proj.js").ProjectionLike} projection The target projection.
78
+ * @return {Array<number>} The extent in the target projection.
79
+ * @api
80
+ */
81
+ export function toOlExtent(bbox, projection) {
82
+ return transformExtent(toContinuousBBox(bbox), 'EPSG:4326', projection);
83
+ }
36
84
  const transparentFill = new Fill({ color: 'rgba(0,0,0,0)' });
37
85
  /**
38
86
  * Check whether the installed OL version is at least the given version.
@@ -135,6 +183,73 @@ export async function getStacObjectsForEvent(event, exclude = null, selectedFeat
135
183
  });
136
184
  return [...objects];
137
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
+ }
138
253
  /**
139
254
  * Get the source info for the GeoTiff from the asset.
140
255
  * @param {import('stac-js').Asset} asset The asset to read the information from.
@@ -157,22 +272,7 @@ export function getGeoTiffSourceInfoFromAsset(asset, selectedBands) {
157
272
  const nodataValues = new Array(bandCount).fill(undefined);
158
273
  let index = 0;
159
274
  for (const source of sources) {
160
- const stats = source.getStatistics();
161
- let { minimum, maximum } = stats;
162
- const { mean, stddev } = stats;
163
- // Use mean ± 2σ for a better visualization stretch (~95% of values)
164
- if (typeof mean === 'number' && typeof stddev === 'number' && stddev > 0) {
165
- const stretchMin = mean - 2 * stddev;
166
- const stretchMax = mean + 2 * stddev;
167
- minimum =
168
- typeof minimum === 'number'
169
- ? Math.max(minimum, stretchMin)
170
- : stretchMin;
171
- maximum =
172
- typeof maximum === 'number'
173
- ? Math.min(maximum, stretchMax)
174
- : stretchMax;
175
- }
275
+ const { minimum, maximum } = getStatisticsStretch(source.getStatistics());
176
276
  if (typeof minimum === 'number') {
177
277
  minValues[index] = minimum;
178
278
  }
@@ -188,16 +288,27 @@ export function getGeoTiffSourceInfoFromAsset(asset, selectedBands) {
188
288
  }
189
289
  index++;
190
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 : [];
191
296
  const defined = (v) => v !== undefined;
192
- if (minValues.some(defined)) {
193
- sourceInfo.min = perBand
194
- ? minValues
195
- : 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];
196
300
  }
197
- if (maxValues.some(defined)) {
198
- sourceInfo.max = perBand
199
- ? maxValues
200
- : 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
+ }
201
312
  }
202
313
  if (nodataValues.some(defined)) {
203
314
  if (perBand) {
@@ -210,6 +321,13 @@ export function getGeoTiffSourceInfoFromAsset(asset, selectedBands) {
210
321
  }
211
322
  }
212
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
+ }
213
331
  if (selectedBands.length > 0) {
214
332
  sourceInfo.bands = selectedBands
215
333
  .map((band) => {
@@ -227,13 +345,26 @@ export function getGeoTiffSourceInfoFromAsset(asset, selectedBands) {
227
345
  .filter((band) => band !== null);
228
346
  }
229
347
  else {
230
- const visualBands = asset.findVisualBands();
231
- if (visualBands) {
232
- sourceInfo.bands = [
233
- visualBands.red.getIndex() + 1,
234
- visualBands.green.getIndex() + 1,
235
- visualBands.blue.getIndex() + 1,
236
- ];
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
+ }
237
368
  }
238
369
  }
239
370
  return sourceInfo;
@@ -257,6 +388,13 @@ export function getBoundsStyle(originalStyle, layerGroup) {
257
388
  /**
258
389
  * Parse the GeoZarr source options from an asset.
259
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
+ * selector 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
+ *
260
398
  * @param {Asset} asset The asset to read the information from.
261
399
  * @param {Array<number|string>} selectedBands The bands to show. One-based index of the band, or the name of the band.
262
400
  * @return {Object} The GeoZarr source options
@@ -266,6 +404,38 @@ export function getGeoZarrSourceOptionsFromAsset(asset, selectedBands) {
266
404
  const options = {
267
405
  url: asset.getAbsoluteUrl(),
268
406
  };
407
+ const cube = getDatacubeRenderingInfo(asset);
408
+ if (cube) {
409
+ options.variable = cube.variable;
410
+ options.selector = {};
411
+ if (cube.bandDimension) {
412
+ const indices = getDatacubeBandIndices(cube.bandDimension.values, selectedBands, asset);
413
+ if (indices.length > 0) {
414
+ options.selector[cube.bandDimension.name] = indices;
415
+ }
416
+ }
417
+ for (const dimension of cube.extraDimensions) {
418
+ options.selector[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
+ }
269
439
  if (selectedBands.length > 0) {
270
440
  options.bands = selectedBands
271
441
  .map((band) => {
@@ -277,24 +447,581 @@ export function getGeoZarrSourceOptionsFromAsset(asset, selectedBands) {
277
447
  if (isObject(bandObj) && typeof bandObj.name === 'string') {
278
448
  return bandObj.name;
279
449
  }
280
- // eslint-disable-next-line no-console
281
- console.error(`Band with index ${band} not found in asset ${asset.getKey()}`);
282
450
  return null;
283
451
  })
284
452
  .filter(Boolean);
285
453
  }
286
454
  else {
287
- const bands = asset.findVisualBands();
288
- if (bands) {
289
- options.bands = [
290
- bands.red.name,
291
- bands.green.name,
292
- bands.blue.name,
293
- ].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
+ }
294
472
  }
295
473
  }
474
+ if (!Array.isArray(options.bands)) {
475
+ options.bands = [];
476
+ }
296
477
  return options;
297
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.selector)) {
736
+ for (const key in sourceOptions.selector) {
737
+ if (Array.isArray(sourceOptions.selector[key])) {
738
+ bandCount = sourceOptions.selector[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
+ }
298
1025
  /**
299
1026
  * Get a URL from a web-map-link that is specific enough, i.e.
300
1027
  * replaces any occurances of {s} if possible, otherwise returns null.
@@ -356,6 +1083,10 @@ export function getClassificationClasses(asset, bands) {
356
1083
  classes = bandObj['classification:classes'];
357
1084
  }
358
1085
  }
1086
+ else if ((!bands || bands.length === 0) && assetBands.length === 1) {
1087
+ // A single-band asset without an explicit selection
1088
+ classes = assetBands[0]['classification:classes'];
1089
+ }
359
1090
  // Fall back to asset-level classification or single band
360
1091
  if (!Array.isArray(classes)) {
361
1092
  classes = asset.getMetadata('classification:classes');
@@ -373,10 +1104,12 @@ export function getClassificationClasses(asset, bands) {
373
1104
  *
374
1105
  * @param {import('stac-js').Asset} asset The STAC asset
375
1106
  * @param {Array<number>} [bands] The selected bands (one-based)
1107
+ * @param {number} [styleBand] The one-based band to style, if it differs
1108
+ * from the selected band (e.g. when the source only loads the selected band)
376
1109
  * @return {Object|null} A WebGL tile layer style object, or null
377
1110
  * @api
378
1111
  */
379
- export function getClassificationStyle(asset, bands) {
1112
+ export function getClassificationStyle(asset, bands, styleBand = null) {
380
1113
  const classes = getClassificationClasses(asset, bands);
381
1114
  if (!classes) {
382
1115
  return null;
@@ -389,9 +1122,12 @@ export function getClassificationStyle(asset, bands) {
389
1122
  return null;
390
1123
  }
391
1124
  // Build the match expression: ['match', ['band', n], value, color, ..., fallback]
392
- let band = 1;
393
- if (bands && bands.length === 1) {
394
- band = bands[0];
1125
+ let band = styleBand;
1126
+ if (!band) {
1127
+ band = 1;
1128
+ if (bands && bands.length === 1) {
1129
+ band = bands[0];
1130
+ }
395
1131
  }
396
1132
  const matchExpr = ['match', ['band', band]];
397
1133
  for (const cls of classesWithColor) {
@@ -403,4 +1139,80 @@ export function getClassificationStyle(asset, bands) {
403
1139
  matchExpr.push(['color', 0, 0, 0, 0]);
404
1140
  return { color: matchExpr };
405
1141
  }
1142
+ /**
1143
+ * Creates a WebGLTileLayer style for a GeoTIFF layer from the STAC metadata.
1144
+ *
1145
+ * A usable render from the render extension (see {@link isUsableRender})
1146
+ * defines the complete visualization: for single-band data its `colormap`
1147
+ * provides the colors (see {@link createColormapStyle} for the supported
1148
+ * forms), applied over the range from its `rescale`.
1149
+ *
1150
+ * Without a usable render, the classification extension
1151
+ * (`classification:classes`) provides the coloring for single-band
1152
+ * categorical data (not applied to floating point data, which is assumed
1153
+ * to be continuous), and continuous single-band data is colored with the
1154
+ * given default colormap (over the value range from the statistics).
1155
+ *
1156
+ * When no style is returned, the value ranges from
1157
+ * {@link getGeoTiffSourceInfoFromAsset} stretch the data (to grayscale for
1158
+ * a single band) instead. The returned styles operate on the raw data
1159
+ * values, i.e. the GeoTIFF source must be configured with
1160
+ * `normalize: false`.
1161
+ *
1162
+ * @param {import('stac-js').Asset} asset The STAC asset
1163
+ * @param {import('ol/source/GeoTIFF.js').SourceInfo} sourceInfo The source info
1164
+ * (for the selected bands).
1165
+ * @param {Array<import('ol/color.js').Color|string>|null} [defaultColormap] The colors
1166
+ * of the colormap for continuous single-band data, evenly distributed over the value range.
1167
+ * @return {Object|null} A WebGL tile layer style object, or null
1168
+ * @api
1169
+ */
1170
+ export function getGeoTiffStyleFromAsset(asset, sourceInfo, defaultColormap = null) {
1171
+ const selected = Array.isArray(sourceInfo.bands) ? sourceInfo.bands : [];
1172
+ const bandCount = selected.length > 0
1173
+ ? selected.length
1174
+ : Math.max(asset.getBands().length, 1);
1175
+ const render = getRenderForAsset(asset);
1176
+ if (isUsableRender(render)) {
1177
+ if (bandCount === 1 && render['colormap']) {
1178
+ const rescale = Array.isArray(render.rescale) ? render.rescale : [];
1179
+ const range = Array.isArray(rescale[0]) && rescale[0].length >= 2 ? rescale[0] : null;
1180
+ return createColormapStyle(render['colormap'], range);
1181
+ }
1182
+ // The value range from the render's rescale is applied through the
1183
+ // source info instead
1184
+ return null;
1185
+ }
1186
+ if (bandCount !== 1) {
1187
+ return null;
1188
+ }
1189
+ // Default visualization: the classification extension for categorical
1190
+ // data (floating point data is assumed to be continuous)
1191
+ const bands = asset.getBands();
1192
+ let bandSource = asset;
1193
+ if (selected.length === 1 && bands.length > 0) {
1194
+ bandSource = bands[selected[0] - 1] || asset;
1195
+ }
1196
+ else if (selected.length === 0 && bands.length === 1) {
1197
+ bandSource = bands[0];
1198
+ }
1199
+ if (!isFloatingPoint(bandSource)) {
1200
+ // The source only loads the selected bands, so a single selected band
1201
+ // is rendered as band 1
1202
+ const classification = getClassificationStyle(asset, selected, selected.length === 1 ? 1 : null);
1203
+ if (classification) {
1204
+ return classification;
1205
+ }
1206
+ }
1207
+ // Continuous data is colored with the default colormap, if given
1208
+ if (Array.isArray(defaultColormap) && defaultColormap.length >= 2) {
1209
+ const range = getStatisticsRange(bandSource) ||
1210
+ (bandSource !== asset ? getStatisticsRange(asset) : null);
1211
+ if (range) {
1212
+ return createGradientStyle(defaultColormap, 1, range);
1213
+ }
1214
+ }
1215
+ // Otherwise it is stretched to grayscale through the source info
1216
+ return null;
1217
+ }
406
1218
  //# sourceMappingURL=util.js.map