Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 87 additions & 36 deletions app/dataset-catalog.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
* categorical-legend shape (`{ name, 'color-hint' }`) the legend renderer
* expects. Accepts the documented `{ label, color }` form as well as the
* STAC-style `{ name | value, 'color-hint' }` form so either author style
* works. Rasters keep deriving their classes from STAC `classification:classes`.
* works. Rasters use it too, in place of STAC `classification:classes` (#350).
* @param {Array<Object>} raw
* @returns {Array<Object>|null}
*/
Expand All @@ -30,6 +30,75 @@ function normalizeLegendClasses(raw) {
}));
}

/**
* Resolve the band-dependent settings of a COG asset from its STAC
* `raster:bands` and the asset's config.
*
* - `bidx` (1-based) picks the band of a multi-band COG (#357); the band's own
* `nodata` and `classification:classes` are then read from that band.
* - Config `legend_classes` overrides the STAC classes (#350). They also build
* the TiTiler colormap, so leaving a class out makes it transparent.
* - A class whose value is the band's nodata is dropped, so fill codes such as
* NLCD 250 never paint.
* - `unscale` asks TiTiler to apply the band's scale and offset, so `rescale`
* and the legend are in real units. It defaults to on when STAC gives a
* scale other than 1 or an offset other than 0; config `unscale` overrides.
*
* @param {Object} asset STAC asset
* @param {Object} config asset config from layers-input.json
* @returns {{bidx:number|null, nodata:*, legendClasses:Array|null, unscale:boolean}}
*/
export function resolveRasterBand(asset, config = {}) {
const bidx = Number.isInteger(Number(config.bidx)) && Number(config.bidx) >= 1 ? Number(config.bidx) : null;
const band = asset?.['raster:bands']?.[(bidx ?? 1) - 1];
const nodata = config.nodata ?? band?.nodata ?? null;
let legendClasses = normalizeLegendClasses(config.legend_classes) ?? band?.['classification:classes'] ?? null;
if (legendClasses && nodata != null) {
legendClasses = legendClasses.filter(c => c.value == null || String(c.value) !== String(nodata));
}
const scale = band?.scale != null ? Number(band.scale) : 1;
const offset = band?.offset != null ? Number(band.offset) : 0;
const unscale = typeof config.unscale === 'boolean' ? config.unscale : (scale !== 1 || offset !== 0);
return { bidx, nodata, legendClasses: legendClasses?.length ? legendClasses : null, unscale };
}

// Past this length a tile URL is at risk of being refused outright by the
// tile server or a proxy (an 832-class inline colormap made 47 kB, #350).
const MAX_TILE_URL = 8000;

/**
* Build a TiTiler COG tile URL for a raster layer.
* @param {string} titilerUrl
* @param {string} cogUrl
* @param {Object} o { legendType, legendClasses, colormap, rescale, nodata, bidx, unscale }
* @returns {string}
*/
export function cogTileUrl(titilerUrl, cogUrl, o) {
let url = `${titilerUrl}/cog/tiles/WebMercatorQuad/{z}/{x}/{y}.png?url=${encodeURIComponent(cogUrl)}`;
if (o.bidx != null) url += `&bidx=${encodeURIComponent(o.bidx)}`;
if (o.unscale) url += '&unscale=true';
if (o.legendType === 'categorical' && o.legendClasses?.length) {
// Inline colormap from the classes' color hints (STAC "RRGGBB" or config "#RRGGBB").
const cmap = {};
for (const cls of o.legendClasses) {
const h = String(cls['color-hint'] || cls.color_hint || '').replace(/^#/, '');
if (/^[0-9a-fA-F]{6}$/.test(h) && cls.value != null) {
cmap[String(cls.value)] = [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16), 255];
}
}
url += `&colormap=${encodeURIComponent(JSON.stringify(cmap))}`;
} else {
url += `&colormap_name=${o.colormap || 'reds'}`;
if (o.rescale) url += `&rescale=${o.rescale}`;
}
if (o.nodata != null) url += `&nodata=${encodeURIComponent(o.nodata)}`;
if (url.length > MAX_TILE_URL) {
console.warn(`[catalog] Tile URL for ${cogUrl} is ${url.length} characters, so the tile server may refuse it. ` +
'List only the classes you want in legend_classes to shorten the inline colormap.');
}
return url;
}

export class DatasetCatalog {
constructor() {
/** @type {Map<string, DatasetEntry>} keyed by collection ID */
Expand Down Expand Up @@ -378,14 +447,12 @@ export class DatasetCatalog {
description: vAsset.description || '',
});
} else if (vType.includes('geotiff') || vType.includes('tiff')) {
const band0 = vAsset['raster:bands']?.[0];
versions.push({
label: v.label,
assetId: v.asset_id,
layerType: 'raster',
cogUrl: vAsset.href,
legendClasses: band0?.['classification:classes'] || null,
nodata: config.nodata ?? band0?.nodata ?? null,
...resolveRasterBand(vAsset, config),
description: vAsset.description || '',
});
} else if (vType.includes('geo+json') || vAsset.href?.endsWith('.geojson')) {
Expand Down Expand Up @@ -473,7 +540,7 @@ export class DatasetCatalog {
control: config.control || null,
});
} else if (type.includes('geotiff') || type.includes('tiff')) {
const band0 = asset['raster:bands']?.[0];
const band = resolveRasterBand(asset, config);
layers.push({
assetId: key,
layerType: 'raster',
Expand All @@ -487,8 +554,10 @@ export class DatasetCatalog {
paint: config.paint || null,
legendLabel: config.legend_label || null,
legendType: config.legend_type || null,
legendClasses: band0?.['classification:classes'] || null,
nodata: config.nodata ?? band0?.nodata ?? null,
legendClasses: band.legendClasses,
nodata: band.nodata,
bidx: band.bidx,
unscale: band.unscale,
description: asset.description || '',
defaultVisible: config.visible === true,
defaultFilter: config.default_filter || null,
Expand Down Expand Up @@ -558,6 +627,7 @@ export class DatasetCatalog {
cogUrl: asset.href,
colormap: options.colormap || 'reds',
rescale: options.rescale || null,
...resolveRasterBand(asset),
description: asset.description || '',
defaultVisible: false,
defaultFilter: null,
Expand Down Expand Up @@ -995,19 +1065,15 @@ export class DatasetCatalog {
sourceLayer: v.sourceLayer,
};
} else {
let tilesUrl = `${this.titilerUrl}/cog/tiles/WebMercatorQuad/{z}/{x}/{y}.png?url=${encodeURIComponent(v.cogUrl)}`;
if (ml.legendType === 'categorical' && v.legendClasses?.length) {
const cmap = {};
for (const cls of v.legendClasses) {
const h = cls['color-hint'] || cls.color_hint;
if (h) cmap[String(cls.value)] = [parseInt(h.slice(0,2),16), parseInt(h.slice(2,4),16), parseInt(h.slice(4,6),16), 255];
}
tilesUrl += `&colormap=${encodeURIComponent(JSON.stringify(cmap))}`;
} else {
tilesUrl += `&colormap_name=${ml.colormap || 'reds'}`;
if (ml.rescale) tilesUrl += `&rescale=${ml.rescale}`;
}
if (v.nodata != null) tilesUrl += `&nodata=${encodeURIComponent(v.nodata)}`;
const tilesUrl = cogTileUrl(this.titilerUrl, v.cogUrl, {
legendType: ml.legendType,
legendClasses: v.legendClasses,
colormap: ml.colormap,
rescale: ml.rescale,
nodata: v.nodata,
bidx: v.bidx,
unscale: v.unscale,
});
return {
label: v.label,
type: 'raster',
Expand Down Expand Up @@ -1090,22 +1156,7 @@ export class DatasetCatalog {

configs.push(layerConfig);
} else if (ml.layerType === 'raster') {
let tilesUrl = `${this.titilerUrl}/cog/tiles/WebMercatorQuad/{z}/{x}/{y}.png?url=${encodeURIComponent(ml.cogUrl)}`;
if (ml.legendType === 'categorical' && ml.legendClasses?.length) {
// Build inline colormap JSON from STAC classification:classes color_hint values
const colormap = {};
for (const cls of ml.legendClasses) {
const h = cls['color-hint'] || cls.color_hint;
if (h) {
colormap[String(cls.value)] = [parseInt(h.slice(0,2),16), parseInt(h.slice(2,4),16), parseInt(h.slice(4,6),16), 255];
}
}
tilesUrl += `&colormap=${encodeURIComponent(JSON.stringify(colormap))}`;
} else {
tilesUrl += `&colormap_name=${ml.colormap}`;
if (ml.rescale) tilesUrl += `&rescale=${ml.rescale}`;
}
if (ml.nodata != null) tilesUrl += `&nodata=${encodeURIComponent(ml.nodata)}`;
const tilesUrl = cogTileUrl(this.titilerUrl, ml.cogUrl, ml);

configs.push({
layerId,
Expand Down
7 changes: 5 additions & 2 deletions docs/guide/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,10 +163,13 @@ The agent closes that last gap with `set_legend`, which names the classes (`{"1"
| `display_name` | string | Label in the layer toggle UI. |
| `visible` | boolean | Default visibility. Default: `false`. |
| `colormap` | string | TiTiler colormap name (e.g., `"reds"`, `"blues"`, `"viridis"`). Default: `"reds"`. |
| `rescale` | string | TiTiler min,max range for color scaling (e.g., `"0,150"`). |
| `nodata` | number\|string | Pixel value to render transparent (e.g., `0` to mask ocean/no-data). If unset, falls back to the STAC `raster:bands[0].nodata` value; omit both to leave all pixels opaque. |
| `rescale` | string | TiTiler min,max range for color scaling (e.g., `"0,150"`). The range is in real units when `unscale` is on, and in the stored values otherwise. |
| `bidx` | integer | 1-based band index for a multi-band COG. Default: `1`. Without it, TiTiler refuses a multi-band source with "Source data must be 1 band" and every tile fails. The band's STAC `nodata` and `classification:classes` are read from this band. |
| `unscale` | boolean | Apply the band's scale and offset before coloring, so `rescale` and the legend are in real units. Default: on when the STAC `raster:bands` entry gives a `scale` other than 1 or an `offset` other than 0, as for int16 temperatures stored in hundredths of a degree. |
| `nodata` | number\|string | Pixel value to render transparent (e.g., `0` to mask ocean/no-data). If unset, falls back to the band's STAC `nodata` value; omit both to leave all pixels opaque. Given in stored values, before `unscale`. |
| `legend_label` | string | Label shown next to the color legend. |
| `legend_type` | string | `"categorical"` to use STAC `classification:classes` color codes for a discrete legend. |
| `legend_classes` | array | `{ value, label, color }` entries that replace the STAC `classification:classes` of a categorical raster. They also build the colormap, so a class left out renders transparent. A class whose value is the band's `nodata` is always left out. Keep the list short: each class lengthens the tile URL, and a URL of tens of kilobytes is refused by the tile server. |
| `group` | string | Overrides the collection-level `group` for this specific layer. |
| `sidebar` | boolean | Overrides the collection-level `sidebar` for this specific layer — see [Layers outside the panel](#layers-outside-the-panel). |
| `toggle_group` | string | Layers sharing this value get **one** panel row whose checkbox drives all of them. See [Toggle groups](#toggle-groups). |
Expand Down
80 changes: 79 additions & 1 deletion test/dataset-catalog.test.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { DatasetCatalog } from '../app/dataset-catalog.js';
import { DatasetCatalog, resolveRasterBand, cogTileUrl } from '../app/dataset-catalog.js';

const stacCollection = (overrides = {}) => ({
type: 'Collection',
Expand Down Expand Up @@ -1258,3 +1258,81 @@ describe('DatasetCatalog toggle_group (#349)', () => {
expect(cat.generatePromptCatalog()).toContain('[toggle group "WHP 2023"');
});
});

describe('raster band settings (#357, #350, unscale)', () => {
const sixBands = {
href: 'https://x/rap.tif',
'raster:bands': [1, 2, 3, 4, 5, 6].map(i => ({ nodata: i === 4 ? 255 : 0, 'classification:classes': [{ value: i, 'color-hint': '00ff00' }] })),
};

it('reads nodata and classes from the band bidx picks', () => {
const b = resolveRasterBand(sixBands, { bidx: 4 });
expect(b.bidx).toBe(4);
expect(b.nodata).toBe(255);
expect(b.legendClasses).toEqual([{ value: 4, 'color-hint': '00ff00' }]);
});

it('defaults to band 1 and ignores a bidx that is not a positive integer', () => {
expect(resolveRasterBand(sixBands, {}).bidx).toBe(null);
expect(resolveRasterBand(sixBands, { bidx: 0 }).bidx).toBe(null);
expect(resolveRasterBand(sixBands, { bidx: 1.5 }).bidx).toBe(null);
expect(resolveRasterBand(sixBands, {}).nodata).toBe(0);
});

it('config legend_classes replace the STAC classes', () => {
const asset = { 'raster:bands': [{ 'classification:classes': [
{ value: 1, 'color-hint': 'aa0000' }, { value: 11, 'color-hint': '0000ff' }] }] };
const b = resolveRasterBand(asset, { legend_classes: [{ value: 1, label: 'Low departure', color: '#aa0000' }] });
expect(b.legendClasses).toEqual([{ value: 1, name: 'Low departure', 'color-hint': '#aa0000' }]);
});

it('drops a class whose value is the band nodata', () => {
const asset = { 'raster:bands': [{ nodata: 250, 'classification:classes': [
{ value: 11, 'color-hint': '466b9f' }, { value: 250, 'color-hint': '000000' }] }] };
expect(resolveRasterBand(asset).legendClasses.map(c => c.value)).toEqual([11]);
});

it('turns unscale on when STAC gives a scale or offset, and lets config override it', () => {
const crw = { 'raster:bands': [{ data_type: 'int16', nodata: -32768, scale: 0.01, offset: 0 }] };
expect(resolveRasterBand(crw).unscale).toBe(true);
expect(resolveRasterBand(crw, { unscale: false }).unscale).toBe(false);
expect(resolveRasterBand({ 'raster:bands': [{ scale: 1, offset: 0 }] }).unscale).toBe(false);
expect(resolveRasterBand({ 'raster:bands': [{ offset: -273.15 }] }).unscale).toBe(true);
expect(resolveRasterBand({}).unscale).toBe(false);
});

it('cogTileUrl carries bidx and unscale', () => {
const url = cogTileUrl('https://t', 'https://x/a.tif', { colormap: 'rdbu_r', rescale: '-1,4', bidx: 4, unscale: true, nodata: -32768 });
expect(url).toContain('&bidx=4');
expect(url).toContain('&unscale=true');
expect(url).toContain('&rescale=-1,4');
expect(url).toContain('&nodata=-32768');
});

it('cogTileUrl reads #-prefixed config colors into the colormap', () => {
const url = cogTileUrl('https://t', 'https://x/a.tif', {
legendType: 'categorical',
legendClasses: [{ value: 1, 'color-hint': '#ff0000' }, { value: 2, 'color-hint': '00ff00' }],
});
const cmap = JSON.parse(decodeURIComponent(url.split('&colormap=')[1]));
expect(cmap).toEqual({ 1: [255, 0, 0, 255], 2: [0, 255, 0, 255] });
});

it('warns when an inline colormap makes the URL too long', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const classes = Array.from({ length: 832 }, (_, i) => ({ value: i, 'color-hint': '123456' }));
cogTileUrl('https://t', 'https://x/evt.tif', { legendType: 'categorical', legendClasses: classes });
expect(warn).toHaveBeenCalledWith(expect.stringMatching(/may refuse it/));
warn.mockRestore();
});

it('extractMapLayers passes bidx, unscale and config classes through for a raster', () => {
const cat = new DatasetCatalog();
const layers = cat.extractMapLayers({ id: 'rap', title: 'RAP', assets: {
cog: { type: 'image/tiff; application=geotiff', href: 'https://x/rap.tif',
'raster:bands': [{ scale: 1 }, { scale: 0.01, nodata: 9 }] },
} }, {}, [{ key: 'cog', assetId: 'cog', config: { bidx: 2, legend_classes: [{ value: 3, color: '#000000' }] } }]);
expect(layers[0]).toMatchObject({ bidx: 2, unscale: true, nodata: 9 });
expect(layers[0].legendClasses).toEqual([{ value: 3, name: 'Class 3', 'color-hint': '#000000' }]);
});
});
Loading