@carto/ps-cog
v0.8.0
Published
Framework-agnostic client library for rendering cloud-native rasters in the CARTO ecosystem.
Maintainers
Keywords
Readme
@carto/ps-cog
deck.gl layers and helpers to render Cloud Optimized GeoTIFFs in the browser. Any dtype, any band combination, mosaics from STAC or GeoJSON, and private buckets through your own signer. It has no framework code and runs in any deck.gl host.
Needs a browser with WebGL2. ESM only.
Install
npm i @carto/ps-cog proj4 @cogeotiff/core \
@deck.gl/core @deck.gl/layers @deck.gl/geo-layers @deck.gl/mesh-layers \
@luma.gl/core @luma.gl/shadertools \
@developmentseed/deck.gl-geotiff @developmentseed/deck.gl-raster \
@developmentseed/geotiff @developmentseed/projInstall the exact peer versions this package lists (npm view @carto/ps-cog peerDependencies). Keep one copy of proj4, @deck.gl/* and @luma.gl/* in the app.
Bundler setup
Vite
// vite.config.ts
export default {
resolve: { dedupe: ["proj4", "@deck.gl/core", "@deck.gl/layers", "@luma.gl/core"] },
worker: { format: "es" },
assetsInclude: ["**/*.csv.gz"],
build: { target: "es2022" },
optimizeDeps: { esbuildOptions: { target: "es2022" } },
};Other bundlers need the same four things. One resolved proj4 module, ES module workers, .csv.gz served as assets, and an ES2022 target. Never split deck.gl, luma.gl or loaders.gl across manual chunks, the app crashes at startup.
LERC wasm
LERC and LERC_ZSTD COGs need no bundler rule. The library decodes them with its own lerc dependency and points it at new URL("lerc/lerc-wasm.wasm", import.meta.url), which Vite (build and dev), Rollup and webpack 5 rewrite to the wasm they emit, hashed like any other asset. lerc 4 on its own fetches lerc-wasm.wasm from the folder of its chunk, where no bundler puts it, and under webpack 5 that folder is a build time file:// URL.
Where that URL cannot resolve, for example a page with no bundler, a CDN build or a bundler that leaves new URL(..., import.meta.url) untouched, serve lerc-wasm.wasm from the lerc version this package resolves (npm ls lerc, the glue and the wasm must match) and set it before the first LERC tile decodes.
import { configureLercWasm } from "@carto/ps-cog/cog";
configureLercWasm("https://static.example.com/lerc-4.2.0/lerc-wasm.wasm");lerc loads its wasm once per page, so a later call throws. The override covers main thread decoding, the default. A worker DecoderPool passed as pool decodes LERC inside the worker with the upstream loader, which still looks beside its own chunk.
Quick start
// Vite. Elsewhere use new URL("@developmentseed/deck.gl-raster/gpu-modules/colormaps.png", import.meta.url).href
import colormapsUrl from "@developmentseed/deck.gl-raster/gpu-modules/colormaps.png?url";
import {
COGTileLayer, clampBounds, computeCogStats, computeExpressionStats, DEFAULT_CONCURRENCY_LIMITER,
loadColormapSprite, makeMultiBandTileLoader, openCog, parseBandExpression,
resolveAlphaBand, resolveCogRendering, type ResolveCogRenderingOptions,
} from "@carto/ps-cog/cog";
const signal = new AbortController().signal;
const geotiff = await openCog(url, { signal, concurrencyLimiter: DEFAULT_CONCURRENCY_LIMITER });
const [sprite, stats, alphaBand] = await Promise.all([
loadColormapSprite(colormapsUrl),
computeCogStats(geotiff, signal),
resolveAlphaBand(geotiff, signal),
]);
const bands = [1, 2, 3].filter((b) => b <= geotiff.count);
if (alphaBand != null && !bands.includes(alphaBand)) bands.push(alphaBand);
const getTileData = makeMultiBandTileLoader(bands, { colormapSprite: sprite });
const cogLayer = (controls: Partial<ResolveCogRenderingOptions> = {}) =>
new COGTileLayer({
id: `cog:${bands.join(".")}`,
geotiff,
getTileData,
renderTile: resolveCogRendering(geotiff, { sprite, stats, alphaBand, ...controls }).renderTile,
onGeoTIFFLoad: (_, { geographicBounds }) => fitTo(clampBounds(geographicBounds)),
onTileError: console.error,
});A pixel-interleaved 8-bit COG also renders with just new COGTileLayer({ id, geotiff }), with no rescale and alpha forced opaque.
Put it on a map
The layer is a plain deck.gl layer.
| Host | Add | Update |
|---|---|---|
| deck.gl | new Deck({ layers: [cogLayer()] }) | deck.setProps({ layers }) |
| MapLibre or Mapbox | map.addControl(new MapboxOverlay({ layers })) | overlay.setProps({ layers }) |
| React | <DeckGL layers={[layer]} /> | new layers prop |
| Google Maps | new GoogleMapsOverlay({ layers }) on a vector map | overlay.setProps({ layers }) |
Load @carto/ps-cog/cog with a dynamic import() to keep it out of the entry chunk. The Google Maps overlay reports a 0x0 viewport, so read the size from map.getDiv() before fitting bounds.
In a component framework, run one abortable load per URL, abort it on cleanup and drop results from a load that was replaced. React StrictMode runs effects twice. Create getTileData once per opened file, never during render. onGeoTIFFLoad fires again when the layer is rebuilt, so fit the camera once per URL.
No bundler
A plain HTML page loads the package from esm.sh through an import map, with no build step. The page below is rendered in headless Chromium against a LERC_ZSTD and a DEFLATE COG on every change and once a week. Save it as a file, serve the folder over HTTP and open it with ?url=<cog url>. Keep ?target=es2022 on every esm.sh URL, the map keys carry it.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>ps-cog with no bundler</title>
<script type="importmap">
{
"imports": {
"https://esm.sh/@cogeotiff/core@^9.5.0?target=es2022": "https://esm.sh/@cogeotiff/[email protected]?target=es2022",
"https://esm.sh/@deck.gl/core@^9.4.0?target=es2022": "https://esm.sh/@deck.gl/[email protected]?target=es2022",
"https://esm.sh/@deck.gl/core@~9.4.0?target=es2022": "https://esm.sh/@deck.gl/[email protected]?target=es2022",
"https://esm.sh/@deck.gl/extensions@~9.4.0?target=es2022": "https://esm.sh/@deck.gl/[email protected]?target=es2022",
"https://esm.sh/@deck.gl/geo-layers@^9.4.0?target=es2022": "https://esm.sh/@deck.gl/[email protected]?target=es2022",
"https://esm.sh/@deck.gl/layers@^9.4.0?target=es2022": "https://esm.sh/@deck.gl/[email protected]?target=es2022",
"https://esm.sh/@deck.gl/layers@~9.4.0?target=es2022": "https://esm.sh/@deck.gl/[email protected]?target=es2022",
"https://esm.sh/@deck.gl/mesh-layers@^9.4.0?target=es2022": "https://esm.sh/@deck.gl/[email protected]?target=es2022",
"https://esm.sh/@deck.gl/mesh-layers@~9.4.0?target=es2022": "https://esm.sh/@deck.gl/[email protected]?target=es2022",
"https://esm.sh/@developmentseed/affine@^0.8.1?target=es2022": "https://esm.sh/@developmentseed/[email protected]?target=es2022",
"https://esm.sh/@developmentseed/deck.gl-raster@^0.8.1/gpu-modules?target=es2022": "https://esm.sh/@developmentseed/[email protected]/gpu-modules?target=es2022",
"https://esm.sh/@developmentseed/deck.gl-raster@^0.8.1?target=es2022": "https://esm.sh/@developmentseed/[email protected]?target=es2022",
"https://esm.sh/@developmentseed/geotiff@^0.8.1?target=es2022": "https://esm.sh/@developmentseed/[email protected]?target=es2022",
"https://esm.sh/@developmentseed/lzw-tiff-decoder@^0.2.2?target=es2022": "https://esm.sh/@developmentseed/[email protected]?target=es2022",
"https://esm.sh/@developmentseed/morecantile@^0.8.1?target=es2022": "https://esm.sh/@developmentseed/[email protected]?target=es2022",
"https://esm.sh/@developmentseed/proj@^0.8.1?target=es2022": "https://esm.sh/@developmentseed/[email protected]?target=es2022",
"https://esm.sh/@developmentseed/raster-reproject@^0.8.1?target=es2022": "https://esm.sh/@developmentseed/[email protected]?target=es2022",
"https://esm.sh/@luma.gl/core@^9.4.0?target=es2022": "https://esm.sh/@luma.gl/[email protected]?target=es2022",
"https://esm.sh/@luma.gl/core@~9.4.0?target=es2022": "https://esm.sh/@luma.gl/[email protected]?target=es2022",
"https://esm.sh/@luma.gl/engine@^9.4.0?target=es2022": "https://esm.sh/@luma.gl/[email protected]?target=es2022",
"https://esm.sh/@luma.gl/engine@~9.4.0?target=es2022": "https://esm.sh/@luma.gl/[email protected]?target=es2022",
"https://esm.sh/@luma.gl/gltf@^9.4.0?target=es2022": "https://esm.sh/@luma.gl/[email protected]?target=es2022",
"https://esm.sh/@luma.gl/gpgpu@^9.4.0/webgpu?target=es2022": "https://esm.sh/@luma.gl/[email protected]/webgpu?target=es2022",
"https://esm.sh/@luma.gl/gpgpu@^9.4.0?target=es2022": "https://esm.sh/@luma.gl/[email protected]?target=es2022",
"https://esm.sh/@luma.gl/shadertools@^9.4.0?target=es2022": "https://esm.sh/@luma.gl/[email protected]?target=es2022",
"https://esm.sh/@luma.gl/shadertools@~9.4.0?target=es2022": "https://esm.sh/@luma.gl/[email protected]?target=es2022",
"https://esm.sh/@luma.gl/webgl@^9.4.0/constants?target=es2022": "https://esm.sh/@luma.gl/[email protected]/constants?target=es2022",
"https://esm.sh/@luma.gl/webgl@^9.4.0?target=es2022": "https://esm.sh/@luma.gl/[email protected]?target=es2022",
"https://esm.sh/lerc@^4.1.2?target=es2022": "https://cdn.jsdelivr.net/npm/[email protected]/LercDecode.es.js",
"https://esm.sh/lerc@^4.2.0?target=es2022": "https://cdn.jsdelivr.net/npm/[email protected]/LercDecode.es.js",
"https://esm.sh/proj4@^2.20.9?target=es2022": "https://esm.sh/[email protected]?target=es2022",
"https://esm.sh/proj4@^2.22.0?target=es2022": "https://esm.sh/[email protected]?target=es2022",
"https://esm.sh/wkt-parser@^1.5.5?target=es2022": "https://esm.sh/[email protected]?target=es2022"
}
}
</script>
<style>
html, body { margin: 0; height: 100%; background: #fff; }
#map { position: relative; width: 100%; height: 100%; }
</style>
</head>
<body>
<div id="map"></div>
<script type="module">
import {
COGTileLayer, clampBounds, computeCogStats, configureLercWasm, DEFAULT_CONCURRENCY_LIMITER,
loadColormapSprite, makeMultiBandTileLoader, openCog, resolveAlphaBand, resolveCogRendering,
} from "https://esm.sh/@carto/[email protected]/cog?target=es2022";
import { Deck, WebMercatorViewport } from "https://esm.sh/@deck.gl/[email protected]?target=es2022";
// Runs before the first LERC tile. The wasm must come from the same lerc version as the glue.
configureLercWasm("https://cdn.jsdelivr.net/npm/[email protected]/lerc-wasm.wasm");
const url = new URLSearchParams(location.search).get("url")
?? "https://sentinel-cogs.s3.us-west-2.amazonaws.com/sentinel-s2-l2a-cogs/36/Q/WD/2020/7/S2A_36QWD_20200701_0_L2A/TCI.tif";
const signal = new AbortController().signal;
const geotiff = await openCog(url, { signal, concurrencyLimiter: DEFAULT_CONCURRENCY_LIMITER });
const [sprite, stats, alphaBand] = await Promise.all([
loadColormapSprite("https://cdn.jsdelivr.net/npm/@developmentseed/[email protected]/dist/gpu-modules/colormaps.png"),
computeCogStats(geotiff, signal),
resolveAlphaBand(geotiff, signal),
]);
const bands = [1, 2, 3].filter((b) => b <= geotiff.count);
if (alphaBand != null && !bands.includes(alphaBand)) bands.push(alphaBand);
const deck = new Deck({
parent: document.getElementById("map"),
initialViewState: { longitude: 0, latitude: 0, zoom: 1 },
controller: true,
layers: [
new COGTileLayer({
id: `cog:${bands.join(".")}`,
geotiff,
getTileData: makeMultiBandTileLoader(bands, { colormapSprite: sprite }),
renderTile: resolveCogRendering(geotiff, { sprite, stats, alphaBand }).renderTile,
onGeoTIFFLoad: (_, { geographicBounds }) => {
const { west, south, east, north } = clampBounds(geographicBounds);
const viewport = new WebMercatorViewport({ width: innerWidth, height: innerHeight });
const { longitude, latitude, zoom } = viewport.fitBounds([[west, south], [east, north]]);
deck.setProps({ initialViewState: { longitude, latitude, zoom } });
},
onTileError: console.error,
}),
],
});
</script>
</body>
</html>The pins in this page are generated for @carto/ps-cog 0.8.0, the release this README ships with, and the dependency versions its repository tests. Every release regenerates them, so for another version copy the page from that version's README.
- LERC needs both fixes. esm.sh's own
lercbuild takes the Node code path and throwsmodule.require is not implemented yet, so the map sends everylercrange in the graph to the jsdelivr ES build. The library then asks forlerc-wasm.wasmbeside its esm.sh chunk, where none exists, soconfigureLercWasmpoints it at the wasm of the samelercversion. Either fix alone fails withboth async and sync fetching of the wasm failedor themodule.requireerror. - One instance of
@luma.gl/core,@deck.gl/coreandproj4per page. Two luma.gl copies break the shader module ABI, two deck.gl copies mix layer classes from two builds, and twoproj4registries split the definitions the library writes from the ones deck.gl-geotiff reads, so the COG renders black. ImportDeck, andproj4if the page uses it, from the exact URLs the map pins to, never from a second CDN or another version. jsdelivr+esmbuilds load two luma.gl copies, so keep the whole graph on esm.sh. - Pinning the package URL does not pin its graph. The dependencies of
@carto/ps-cogdeclare^and~ranges, and esm.sh resolves each range on every request, so a patch release upstream can load a second copy next to the one the package imports. The map pins every range of@deck.gl/*,@luma.gl/*,@developmentseed/*(the raster train and itslzw-tiff-decoder) and@cogeotiff/core, plusproj4,lercandwkt-parser.proj4asks forwkt-parser@^1.5.5while@developmentseed/projimports the exact version, so an unpinned range loads it twice. List the ranges one module imports withcurl -s <esm.sh module url> | grep -o 'from"/[^"]*"'. Other dependencies such as loaders.gl and math.gl still float inside their ranges, pin them the same way or self host the files for a page that never changes. - Workers are not covered. A worker
DecoderPooldecodes LERC with the upstream loader, so a page with no bundler decodes on the main thread.
Render controls
Rebuild the layer with the same id and getTileData. Only the shader or a uniform changes, no tile is fetched again.
deck.setProps({ layers: [cogLayer({
colormapName: "magma", // any key of COLORMAP_INDEX
rescaleOverride: { min: 0, max: 3000 },
domain: "minmax", // or "percentile"
stretch: "sqrt", // "linear" | "sqrt" | "log"
gamma: 1.2,
bandFilter: [{ min: 10, max: 2000 }, null, null],
mode: "singleBand", band: 1, // or "rgb"
})] });
// band math through a colormap, the loader must hold every band it reads
const bandExpression = parseBandExpression("(b2-b1)/(b2+b1)")!;
const bandExpressionStats = await computeExpressionStats(geotiff, bandExpression, { signal });
cogLayer({ mode: "custom", bandExpression, bandExpressionStats });For a first paint or a catalog list that needs only a range, computeCogStats(geotiff, signal, { source: "stored-first" }) answers from the GDAL stored statistics with no tile request when every band has a stored min below its max, and samples otherwise. Its histograms are empty, so the render window is that min and max. mergeBandStats over only such results keeps their union range with an empty histogram. Mixed with sampled results, a stored-first range widens the merged range but adds no counts, so keep them apart when the histogram shape matters. readStoredStats(geotiff) returns the stored min, max, mean, std and validPercent per band, or null for a file without them.
resolveCogRendering also returns legendDescriptor, availableModes and kind for your UI. Rebuild the loader, with a new id, only when a control needs a band that is not loaded. Abort computeExpressionStats when the expression changes. Pass the same nodata override the layer renders with (null for off) so the ramp window leaves out the pixels the map leaves out.
The internal mask and the nodata value follow the stats rule. A pixel the file's internal mask marks empty never draws, nodataOverride: "off" included, since "off" drops only the nodata value and the float NaN guard. Band math discards a pixel when any band it reads is nodata, single band and categorical renders test their one band, and RGB discards only when all three bands read nodata. NaN parts from the stats, which always skip a non-finite value, in two cases. Under "off" a NaN pixel on a float COG draws. In RGB a pixel draws unless all three bands are NaN. A custom renderTile over makeMultiBandTileLoader gets no mask for free, it binds data.mask with upstream MaskTexture itself.
Band plan
neededBands, TileLoaderRegistry and layerIdForBands encode rules 2 and 3 below, so a control change keeps the loader and the id and only a band that is not loaded yet refetches.
import {
COGTileLayer, defaultLoaderBands, layerIdForBands, makeMultiBandTileLoader, neededBands,
resolveCogRendering, TileLoaderRegistry,
} from "@carto/ps-cog/cog";
// once per opened file
const defaults = defaultLoaderBands(geotiff.count);
const loaders = new TileLoaderRegistry(defaults, (b) =>
makeMultiBandTileLoader(b, { colormapSprite: sprite }));
// on every control change
const r = resolveCogRendering(geotiff, { sprite, stats, alphaBand, ...controls });
const loader = loaders.ensure(neededBands({
bandCount: geotiff.count, activeMode: r.activeMode, kind: r.kind, alphaBand,
bands: controls.bands, singleBand: controls.band, bandExpression: controls.bandExpression,
}));
new COGTileLayer({
id: layerIdForBands("cog", loader.bands, defaults),
geotiff,
getTileData: loader.getTileData!, // null only for a registry built with a null factory
renderTile: r.renderTile,
});ensurereturns the sameTileLoaderobject while its bands cover the need, sogetTileDatastays stable and the id stays the barebaseIdfor any file with at most four bands.- A band outside
1..bandCountis dropped, and a need with no valid band falls back todefaults, so a bad control value never yields a bandless tile. CogBandPlanwraps the above for one opened file, with a second registry for the categorical upload.new CogBandPlan("cog", geotiff.count, (bands, categorical) => makeMultiBandTileLoader(bands, { colormapSprite: sprite, categorical })), paintplan.current, then on every control change passplan.select(neededBandsInput, categorical)to the layer asidandgetTileData. A categorical upload rides under<baseId>:categorical, so switching a preset on or off refetches instead of keeping the old tiles.- For a band-math builder,
selectableFormulaBands(formula, bandCount)from/paramsoffers every band until the formula reads four distinct bands, then only those.formulaBandslists the bands a formula reads.appendFormulaToken,removeFormulaTokenandcycleFormulaOpedit a chain, andnormalizeFormulagives what to write toexpr, or null to delete it. - When your layer id is fixed by something else (a layer store that sets
idto its own key), keep the id and passupdateTriggers: { getTileData: bandSetKey(loader.bands) }instead.COGTileLayerandBandStackLayer(whose band set isbandIndexes) turn a change of that trigger into a fresh tileset under the sameid, every tile is fetched again with the new bands and the old tiles' textures are freed. A trigger that stays the same across a control change keeps every tile.BandStackLayeralso refetches whenbandIndexeschanges as a set, trigger or not. The new tileset starts empty, so the layer shows nothing until the first tiles of the new band set decode, the same as with a newid. ChanginggetTileDataorbandIndexeswith neither a newidnor a new trigger keeps the cached tiles on the old bands, and a render that asks for a band a tile does not carry falls back to its first band. isMosaicSublayerId("cog", layer.id)matches the per-source sublayers of a mosaic under that id, for deckonErrorrouting.
Controls and URL state
@carto/ps-cog/params is the shared grammar for render state in a query string. Every control has a parse*Param that never throws and falls back to the default, and a serialize*Param that returns null for the default so it stays out of the URL. It loads no deck.gl, luma.gl, @developmentseed, geotiff or proj4 code at runtime, so it is safe in an entry chunk. Its type declarations still reference @developmentseed types, so a type-checking consumer keeps the peers installed or sets skipLibCheck.
import {
clearStretchWindowParams, encodeFormula, filterToPerBand, normalizedDifference, parseBandsParam,
parseColormapParam, parseCurveParam, parseExprParam, parseFilterParam, parseGammaParam,
parseModeParam, parseNodataParam, parseRescaleParam, parseRgbStretchParam, pinStretchWindow,
serializeCurveParam,
} from "@carto/ps-cog/params";
const q = new URLSearchParams(location.search);
cogLayer({
mode: parseModeParam(q.get("mode")) ?? undefined, // null lets the file kind pick
bands: parseBandsParam(q.get("bands")),
colormapName: parseColormapParam(q.get("cmap")),
rescaleOverride: parseRescaleParam(q.get("vmin"), q.get("vmax")),
perBandRescale: parseRgbStretchParam(q.get("rgbStretch")),
stretch: parseCurveParam(q.get("curve")),
gamma: parseGammaParam(q.get("gamma")),
bandFilter: filterToPerBand(parseFilterParam(q.get("fmin"), q.get("fmax"))),
nodataOverride: parseNodataParam(q.get("nodata")).override,
bandExpression: parseExprParam(q.get("expr")) ?? undefined, // base64url, or infix "(b4-b3)/(b4%2Bb3)" in a URL
});
const curve = serializeCurveParam("sqrt");
if (curve == null) q.delete("curve"); else q.set("curve", curve);
q.set("expr", encodeFormula(normalizedDifference(4, 3)));
clearStretchWindowParams(q); // a new band or expression invalidates vmin, vmax, rgbStretch, fmin, fmax
pinStretchWindow(q, autoWindow); // before copying a share link, so a mosaic rescan cannot drift itA number param that is blank or not finite reads as absent, never as 0. Written bounds keep three decimals at 1 and above and three significant digits below, so a small float window survives a round trip. parseExprParam returns null for an expression over four distinct bands or longer than MAX_EXPR_PARAM_LENGTH, and an infix + in a URL must be sent as %2B.
| Param | Codec |
|---|---|
| mode, band | parseModeParam, parseSingleBandParam |
| bands, rgbStretch | parseBandsParam, parseRgbStretchParam |
| vmin, vmax | parseRescaleParam, serializeRescaleBound, pinStretchWindow |
| fmin, fmax | parseFilterParam, serializeFilterMin, serializeFilterMax, filterToPerBand |
| cmap | parseColormapParam, serializeColormapParam |
| domain, curve, gamma, maxError | parseDomainParam, parseCurveParam, parseGammaParam, parseMaxErrorParam |
| expr | parseExprParam, encodeFormula, decodeFormula, presetFormula, defaultPresetBands |
| preset, nodata, inspect | parsePresetParam, parseNodataParam, parseInspectParam |
| stats-scope, stats-refresh, stats-cap, area-scope | parseStatsScopeParam, parseStatsRefreshParam, parseStatsCapParam, parseAreaScopeParam |
| url | parseUrlParams, setUrlParams, splitUrlInput, joinUrls |
The defaults (DEFAULT_COLORMAP, DEFAULT_DOMAIN, DEFAULT_CURVE, DEFAULT_GAMMA, DEFAULT_MAX_ERROR, DEFAULT_BANDS, DEFAULT_STATS_CAP) and clamps (MIN_GAMMA, MAX_GAMMA, MIN_MAX_ERROR, MAX_MAX_ERROR, MAX_STATS_CAP, MAX_EXPR_PARAM_LENGTH) are exported, never hard-code them. COG_STATE_PARAM_KEYS lists every key, and clearCogStateParams wipes them when a new file loads. STRETCH_WINDOW_PARAM_KEYS is the subset clearStretchWindowParams drops. The clear helpers mutate and return the instance you pass, so pass a copy if you need the old one. A shared link is only as stable as this grammar, so a change to a name, default or clamp ships as a minor release with a changelog note.
Resolve a preset selection with resolveSelectedPreset(parsePresetParam(q.get("preset")), match?.slug ?? null) from /cog and pass the result as categoryPreset. It returns undefined for none, an auto with no match, or an unknown slug.
Inspector helpers
Pure helpers in /cog that return plain data, so any chart or legend kit can use them.
- Histogram
histogramEdges(n + 1 edges),histogramBinLabels. For a brush,windowToSelectedBarshighlights the current window andselectedBarsToWindowturns brushed bars into arescaleOverride. - Metadata
metadataRowswith a stablekeyper row for translation,fileBasename,bandOptionLabel. - Legend
legendNumber,shadeLegendRamp(dims stops outside a filter),rgbChannelLegend,CHANNEL_COLORS,CHANNEL_LABELS,rgbToHex,hexToRgb. - Colormaps
colormapGradient(sprite, COLORMAP_INDEX[name])returns a CSS gradient for a picker. It throws on a row outside the sprite.colormapGradientByName(sprite, name)takes the name instead and returns null for a sprite still loading, an unknown name or a missing row, so a name straight from the URL is safe. - Errors
isAbortErrorfor the error an aborted read throws. It also matches an error whosecausechain holds the AbortError, such as theSourceErrora cancelled range fetch rejects with, so check it rather thanerr.name.
Categorical rasters
Presets ship for nlcd, esa-worldcover, io-lulc, sentinel2-scl, dynamic-world, globeland30, cfmask, koppen-geiger, modis-igbp, cgls-lc100, jrc-gsw-transitions, esa-cci-lc, glc-fcs30d, corine and usda-cdl.
const match = matchCategoryPreset(observedCategoryCodes(geotiff));
const categoryPreset = match?.preset ?? CATEGORY_PRESETS["esa-worldcover"];
const getTileData = makeMultiBandTileLoader([1], { colormapSprite: sprite, categorical: true });
// pass { categoryPreset } to resolveCogRenderingUse categorical: true when the file has no embedded ColorMap, so classes are sampled nearest. Build your own preset with defineCategoryPreset.
Mosaics
import { buildMosaicRenderSource, loadMosaicSources, MosaicTileLayer } from "@carto/ps-cog/cog";
const sources = await loadMosaicSources(stacOrGeoJsonUrl, { resolveUrl });
new MosaicTileLayer({ id: "mosaic", sources, resolveHref: resolveUrl });- The default renders unsigned integer sources only. For any other dtype or for render controls,
openCogone source forstatsandresolveCogRendering, then passrenderSource: buildMosaicRenderSource({ layerIdPrefix, getTileData, renderTile, opacity }). - Sublayer ids are
<layerIdPrefix>-<source id or href>. Keep the prefix stable like a layerid. A STAC id is a string, a numeric GeoJSON id is read as its string. STAC ids are unique only within one collection, so when an id repeats in the listloadMosaicSourcesandparseStacMosaicSourcesdrop it from every source that shares it and those sublayers use the href. Dedupe your ownsourcesthe same way if you build them by hand. - Hide a mosaic with
visible: falseand keep it in the layer list, its tile caches survive and showing it again refetches nothing.visibleandopacityset on the mosaic reach every per-source layer,pickabledoes not. A mosaic left at the deck default keeps the valuerenderSourceset per source. - A GeoJSON tile index or VRT in a projected CRS gets each source's WGS84
bboxfrom points along the footprint edges. A footprint that holds the pole of a polar CRS, even on a corner, spans every longitude, so polar tiles still matchfindMosaicSourcesAtand the view filter. A footprint with no point that reprojects is left out of the sources. The bbox stays in the longitude frame of its CRS,[lon_0 - 180, lon_0 + 180], so it can lie outside [-180, 180]. A UTM zone 1 tile across the antimeridian haswestbelow -180, a zone 60 tileeastpast 180, and an EPSG:3413 tile at 150E sits near -210. A tile on the seam of that frame stays one span.findMosaicSourcesAt,filterMosaicSourcesByBounds,rankMosaicSourcesByCenterand theMosaicTileLayerview search all match such a bbox at its real longitude. - Every layer draws on the main world.
COGTileLayer,BandStackLayerand eachMosaicTileLayersource move their mesh by whole turns from the frame of the CRS so its centre lies in [-180, 180), so an EPSG:3413 tile at 150E draws at 150E, and a tile on the seam of that frame draws as one piece. Where the view shows a mesh one turn over, past a world edge, the layer draws it there too, so a UTM zone 1 or 60 tile across the antimeridian draws on both sides and a tile wholly past 180 shows to a view centred on either side. Each extra copy has its own tile cache, so a pan across the antimeridian fetches the tiles of the copy it brings into view, and a raster a full turn wide fetches the tiles both copies show twice. A view that repeats the world (MapViewwithrepeat) or a globe draws the copies itself, and the layer then draws one.onGeoTIFFLoadreportsgeographicBoundsmoved by the same turns, so a camera fit lands where the tile is drawn. The mosaic view search reads a west-greater-than-east STAC bbox on both sides of 180 and matches a bbox on every turn inside one turn around the view centre. - Pass a new
sourcesarray to change the source set. Rendering,filterMosaicSourcesByBoundsandrankMosaicSourcesByCenterplace a source by itsbboxonly. - One render pipeline serves every source, built from the facts of one representative source. A source that differs from it still draws, and each tile still masks its own file's nodata and uploads its own file's palette. What it shares is what was chosen once from the representative. A different band count means the band picks follow the representative, and a source that lacks a picked band draws another of its bands in that slot. A different data type means the rescale window comes from the representative's values, so the contrast can be off, and an RGBA or palette render fails on tiles wider than 8 bits. A different palette means the render mode follows the representative, so a palette source in a mosaic without one draws its class indices as plain values, and a source without a palette in a palette mosaic draws grey unless a preset palette is set. A different nodata value changes no pixel, only the NoData row and the first stats describe the representative and not that source.
MosaicHomogeneityCheckfinds such sources and only reports them. Add each source as it opens, the first one added is the representative, so add the source your pipeline was built from first, and never add one whose stats failed to adopt.add(href, geotiff)returns that source's warnings (field,expected,actual,message),summary()folds them per field for a notice.MOSAIC_HOMOGENEITY_FIELDSgives the field order,MOSAIC_HOMOGENEITY_FIELD_LABELSa label andMOSAIC_HOMOGENEITY_FIELD_EFFECTSthe effect above as one sentence per field. The CRS is not compared, each source reprojects on its own.readMosaicSourceProfileandcompareMosaicSourceProfilesare the pieces it uses, header tags only, no tile request. See the example after this list. - Keep the mosaic unpickable, deck.gl picks at most 255 layers. Find the source under a point with
findMosaicSourcesAtand read it withreadCogValueAt. - A STAC source always carries its
datetime, orstart_datetimewhendatetimeis null. Ask for more withloadMosaicSources(url, { resolveUrl, stacMetadata: { properties: ["eo:cloud_cover"], footprint: true } }), the same options go toparseStacItemSourceandparseStacMosaicSources. Only listed keys the item has land inproperties.rasterStats: truekeeps the COG asset's bandstatisticsandhistogramasrasterBands(StacRasterBand[], index 0 is band 1), read from the asset'sraster:bands(raster extension 1.x) orbands(STAC 1.1,raster:histogram), then the item'sproperties.bands. A malformed histogram is dropped. - With a
footprint(the item'sPolygonorMultiPolygongeometry),findMosaicSourcesAttests the point against the polygon after the bbox, holes included, so a rotated swath no longer matches its empty bbox corners. Sources without one keep the bbox test. Footprints cost memory per item, leave them off for a large collection that never queries points. The footprint arrays are shared with the parsed STAC document and cached per object, never mutate them, pass a new footprint object instead. - A bbox with west greater than east (the STAC antimeridian form) matches on both sides of the 180th meridian, in
findMosaicSourcesAtandfilterMosaicSourcesByBounds. A footprint ring is read as written unless the bbox is full width (-180to180) or crosses the antimeridian. Then an edge longer than 180 degrees goes the short way around (an unsplit crossing ring), an edge from-180to180stays a full-width edge (a world or polar cap box,1e-9of float noise allowed), and a ring that goes once around the globe (a latitude circle, or a cap outline closed along the seam) is closed over the pole the bbox touches, or the north pole for a counterclockwise ring when the bbox touches both. Write a near-global footprint to-180and180, a ring edge from-179.99to179.99reads as a sliver across the antimeridian. - To also scan stats or read pixels, pass a
getSourcethat caches oneGeoTIFFper href so each source opens once.
// Warn on sources that differ from the representative, never reject them.
import { MosaicHomogeneityCheck } from "@carto/ps-cog/cog";
const check = new MosaicHomogeneityCheck();
const openSource = async (source) => {
const geotiff = await openCog(await resolveUrl(source.href), { signal });
for (const w of check.add(source.href, geotiff)) console.warn(w.message, w.href);
return geotiff;
};Point time series
import { isAbortError, loadMosaicSources, readMosaicSeriesAt } from "@carto/ps-cog/cog";
const sources = await loadMosaicSources(stacUrl, { resolveUrl, stacMetadata: { footprint: true } });
try {
const series = await readMosaicSeriesAt(sources, [lng, lat], {
openSource, // the same opener you give scanMosaicStats
bands: [4, 8],
resolution: 20,
from: "2024-01-01",
to: "2024-12-31",
maxSources: 60,
signal,
});
for (const e of series.entries) {
if (e.status === "ok" && !e.nodata) chart.add(e.time, e.values);
}
} catch (err) {
if (!isAbortError(err)) throw err;
}- One entry per source whose bbox, then
footprintwhen set, holds the point, sorted bydatetimeascending. Sources sharing a datetime keep their array order. - A source with no
datetime, or oneDate.parsecannot read, comes after every dated entry in array order withtime: null. Passundated: "skip"to drop them. Afromortobound (ISO string, epoch ms orDate, both inclusive) always drops them. A date-onlytosuch as"2024-12-31"covers that whole UTC day, a date-onlyfromstarts at its UTC midnight, and a date-time with no offset is read in local time, so give it aZor an offset. - Each entry carries
source,id,href,datetime,time(epoch ms),status,valuesandscaledValues(one per requested band, inbandsorder, every band of the file whenbandsis omitted),nodataPerBand,nodataandlevel. A band the file lacks readsNaNand counts as nodata.nodatais true when every requested band is nodata or masked. statusisok,outside(in the bbox or footprint but off the image) orfailed. A failed open or read stays on its entry witherrorset and is counted infailed, it never fails the call. When every source read fails the call rejects with anAggregateErrorholding each error.openSource(source, signal)resolves aGeoTIFFornull(a failure), like the scans. Pass the opener that keeps oneGeoTIFFper href and opens with a backgroundgetPriority, so the series reuses files the map already opened and waits behind visible tiles. The function keeps no cache of its own. The second argument is the call'ssignal, undefined when none was passed. An opener may use it to cancel its own fetch, and a one argument opener keeps working. An opener that shares one open across callers should not cancel that shared open on one caller's signal.expressiontakes aBandExpression(fromparseBandExpression) and setsvalueon each entry, the band math on the raw values of the bands it reads, the arithmetic the stats paths use. Its bands are read whateverbandslists.valueisnullwithout an expression, on an entry that is notok, when any band it reads is nodata, masked or missing from the file, and when the result is not finite (a division by zero). An expression over more than 4 distinct bands throws before any open.levelandresolutionwork as inreadCogValueAt, per source.nodataandpoolpass through too.concurrency(4 by default) sources open and read at once. A series tile never enters the inspect tile cache, so peak memory is aboutconcurrencydecoded tiles and nothing stays pinned on the open files afterwards. A long series downloads one tile per source, so cap it withmaxSources, which keeps the newest dated sources (then undated ones) and never opens the rest.matched,skippedByRange,skippedUndated,skippedByCapandfailedon the result count what was left out.- On abort the call rejects with the signal's reason at once, opens no further source and cancels the tile fetches in flight, test it with
isAbortError. An open already in flight runs on unless your opener honours the signal it is given. An error a source throws after the signal fired counts as the abort. - Bad options throw a
RangeErrorbefore any open, a non positiveconcurrencyor band, a negativemaxSources, an unparseablefromorto, or afromafterto.
Zone time series
import { computeMosaicSeriesStats, isAbortError } from "@carto/ps-cog/cog";
try {
const series = await computeMosaicSeriesStats(sources, polygonFeature, {
openSource,
expression: ndvi,
from: "2024-01-01",
to: "2024-12-31",
maxSources: 24,
signal,
});
for (const e of series.entries) {
if (e.status === "ok" && e.stats?.moments) chart.add(e.time, e.stats.moments.areaWeightedMean);
}
} catch (err) {
if (!isAbortError(err)) throw err;
}- One
computeCogWindowStats(geotiff, zone, options)per source whose bbox meets the zone, the bbox of each polygon of a MultiPolygon on its own, sorted bydatetimelike the point series. The zone is a bbox, aPolygon, aMultiPolygonor aFeaturewrapping one, ornullto read every source whole. from,to,undated,maxSources,concurrency(4 by default) and the abort rule are those ofreadMosaicSeriesAt, andopenSourcegets the signal the same way. The window optionsband,expression,binning,nodata,maxTiles,maxPixelsandpoolpass through to every read.levelis not taken, overview n is a different resolution in each source, so each read stays within the tile and pixel budgets.- Each entry carries
source,id,href,datetime,time,statusandstats, the fullCogWindowStatsof that source in the zone.statusisok,outside(the bbox meets the zone but the image does not, or the file's WGS84 bounds cannot be resolved,stats.levelis -1) orfailedwitherrorset andstatsnull. - A footprint is not tested, a source whose image holds no valid pixel in the zone is an
okentry withvalidPixels0 andmomentsnull. - The zone and the options are validated before any open. A polygon zone throws a
TypeErroror aRangeErrorascomputeCogWindowStatsdoes, a bbox zone with a non-finite edge orsouthabovenorththrows aRangeError, and so do anexpressionover more than 4 distinct bands and abandthat is not a positive integer.scanMosaicWindowStatschecks a bbox zone the same way. When every source read fails the call rejects with anAggregateError. - Memory is about
concurrencywindow reads, each bounded bymaxTilesandmaxPixels. Cap a long series withmaxSources.
Band stacks
Several single-band files as one image. Build it with buildBandStack and take stats from computeBandStackStats. Render with new BandStackLayer({ id, stack, bandIndexes, colormapSprite, renderTile }), where renderTile comes from resolveCogRendering(stack.files[stack.primary], { bandCount: stack.bands.length, ... }). All files need one CRS.
Pixel inspect
readCogValueAt(geotiff, [lng, lat], { level, resolution, nodata, signal }) reads the source pixel under a point from the file, not the GPU, and formatCogReadout(sample, ctx) turns it into readout rows. readBandStackValueAt does the same for a band stack, keyed by virtual band. readCogValuesAt(geotiff, points, options) reads many points at once.
- A point read fetches and decodes the one full tile under it. Before compression that is about
tileWidth * tileHeight * bytesPerSample * bandsbytes, so a single point on a 1024 px, four banduint16tile decodes 8 MiB. - The last four decoded tiles per level stay cached, so hover ticks inside the same tiles fetch nothing. Concurrent reads of one tile share a fetch, which is aborted only once no caller waits on it. A read without a
signalcounts as a waiter until the fetch settles, so eviction never cancels it. - For hover on large tiles pass
level(0full resolution,nforoverviews[n - 1]) to read an overview, and read level0on click. An index past the last overview reads the coarsest one and a negative index reads full resolution. - Or pass
resolutionin metres to let the library pick the level. It reads the coarsest level whose ground pixel is at or below that size, and full resolution when no overview is fine enough. A geographic or EPSG:3857 pixel shrinks with latitude, so its ground size is taken at the point nearest the equator (a 3857 pixel at 60N counts half its nominal size). Other projected CRSs use the nominal size, a CRS whose units cannot be resolved reads full resolution, and a zero, negative or non finite value throws aRangeError.levelwins when both are set.sample.levelreports the level read. readCogValuesAtprojects every point, groups the points by tile, reuses tiles already cached and fetches the rest in groups oftilesPerFetchtiles (16 by default), onefetchTilescall per group, which coalesces neighbouring byte ranges into a few HTTP requests. All points read the same level. It resolves one entry per point in input order,nullfor a point outside the image or one that cannot be projected, with the same sample shape and nodata flags asreadCogValueAt.- A batch samples each group and drops it before fetching the next, so peak memory is about
tilesPerFetchdecoded tiles (about 32 MB for 16 Sentinel-2 L2A 1024 pxuint16tiles) whatever the number of points. A largertilesPerFetchmeans fewer requests and more memory. - A batch never evicts the hover tiles. Its tiles only fill free cache slots, and a hover read in a tile the batch is still fetching waits for that fetch instead of downloading the tile again. Two batches running at once over the same tile each fetch it.
- Both reads reject with the signal's reason on abort, test it with
isAbortError. A batch also rejects when any of its tiles fails. nodataPerBand[i]is true when bandi + 1equals the nodata value (compared at float32 precision for float32 data, so a nodata GDAL wrote with fewer digits still matches), reads NaN, or the internal mask marks the pixel empty. NaN counts even without a declared nodata, like the render's float NaN guard.- Pass the render's override as
nodataso the readout matches the map, a number replaces the file value andnullstands fornodataOverride: "off", which drops the nodata value and the NaN rule but keeps the mask. Undernulla NaN sample reads as a value, the way the render draws it, while the window stats still skip every non-finite value.readBandStackValueAtapplies it to every file. - With
resolutioneach file of a band stack picks its own level, so 10 m and 20 m bands each read at their own scale.level,row,colandcrsCoordinateon the stack sample are the primary file's. - A band stack readout honors each file's internal mask, the stack render does not, so a masked pixel of a stack file reads "No data" where the map draws it. An Infinity sample reads as data, as in a single file read and the render, while the stats skip it.
nodatais true when the mask marks the pixel empty or every band is nodata, the rasteriodataset_maskrule. One band at nodata does not hide the others, so testnodataPerBandfor the band you show.formatCogReadoutapplies the render rule per mode. Band math reads "No data" when any band it reads is nodata, RGB only when all three bands are, single band and categorical when their own band is.
import { isAbortError, readCogValuesAt } from "@carto/ps-cog/cog";
const controller = new AbortController();
try {
const samples = await readCogValuesAt(geotiff, points, { resolution: 30, signal: controller.signal });
samples.forEach((s, i) => console.log(points[i], s?.nodata ? "no data" : s?.values));
} catch (err) {
if (!isAbortError(err)) throw err;
}Area and window stats
Measure how much ground a value range covers, and the zonal sum, mean and std, over the whole file, inside any WGS84 bbox such as the map viewport, or inside a WGS84 GeoJSON polygon such as an admin boundary or a drawn shape. Everything runs on the CPU from real pixels, so it works in any framework and never touches the render path. The stats code itself needs no DOM, but opening the file does. @developmentseed/geotiff parses the GDAL_METADATA tag with DOMParser, so openCog, GeoTIFF.fromUrl and GeoTIFF.fromArrayBuffer throw DOMParser is not defined in Node and in a Web Worker for any COG that carries that tag (stored statistics, scale and offset, band descriptions, which GDAL writes often). To run the stats in a worker or headless, install a DOMParser polyfill such as happy-dom or linkedom there and set globalThis.DOMParser before the first open.
import {
computeCogWindowStats, estimateWindowRangeAreaKm2, scanMosaicWindowStats,
} from "@carto/ps-cog/cog";
const range = { min: 500, max: 1200 };
// Whole file, the finest overview that fits the tile budget.
const whole = await computeCogWindowStats(geotiff, null, { band: 1, maxTiles: 64, signal });
const wholeKm2 = whole && estimateWindowRangeAreaKm2([whole], range);
// Inside a bbox, such as the map viewport, from the pixels in that window.
const view = await computeCogWindowStats(geotiff, { west, south, east, north }, { band: 1, signal });
const viewKm2 = view && estimateWindowRangeAreaKm2([view], range);
// Inside a polygon, a Polygon, MultiPolygon or a Feature wrapping one.
const zone = await computeCogWindowStats(geotiff, feature, { band: 1, level: 0, signal });
const zoneTotal = zone?.moments?.sum;
// A mosaic, every source in the bbox or polygon clipped to it.
const scan = await scanMosaicWindowStats(sources, { bounds, openSource, signal, maxSources: 64 });
const mosaicKm2 = estimateWindowRangeAreaKm2(scan.windows, range);
const mosaicMean = scan.moments?.areaWeightedMean;- Pass
nullas the bbox to read the whole raster. The whole-file read uses the same area math, so prefer it overestimateRangeAreaKm2when accuracy matters (it corrects nodata, feet, Web Mercator and latitude bias). computeCogWindowStatspicks a level coarse to fine. It starts at the coarsest overview and refines one level at a time while the finer level still fitsmaxTiles(defaultDEFAULT_WINDOW_MAX_TILES, tiles of the window's bounding grid) andmaxPixels(defaultDEFAULT_WINDOW_MAX_PIXELS,4 * 2 ** 20, 4 tiles of 1024 px or 1 of 2048 px), and stops at the first level that overflows.maxPixelscounts the pixels actually fetched and held decoded, every tile the zone reaches at its size clipped to the image, not the window's own pixels, so it bounds the download and the decoded memory per band. A window that touches four 1024 px tiles costs 4.2 million pixels even when only 1.2 million of them fall inside it, and an edge tile counts only its part inside the image. A level that reaches a single tile always fits, so a file whose tiles alone are larger thanmaxPixelsstill reads the finest level where the zone sits in one tile. A level inside both budgets reads every tile the zone reaches,coverage1. When even the coarsest level overflows (a file with no overview pyramid), it gets an even tile subsample inside both budgets and acoveragebelow 1, which the area scales back up.- A window read on the
GeoTIFFyou render shares its request queue and priority, so a large read can wait alongside visible tiles. Open a secondGeoTIFFfor the same file withopenCog(url, { getPriority })returning a larger number than the layer's render reads, such asNumber.POSITIVE_INFINITY, if stats must never delay the map. Priorities sort ascending, so the lowest number is served first. - Every pixel carries its own ground area on the WGS84 ellipsoid. Lon/lat and Mercator pixels are measured exactly per row. Any other CRS (UTM, feet-based, polar, equirectangular) and any rotated grid uses the pixel corners projected to lon/lat, so the projection's own units and scale factor are included. The bbox is clipped in pixel space row by row, a pixel counts when its center is inside, so a bbox curved in the file CRS or turned on a rotated grid is followed exactly. A bbox or polygon that holds no pixel center at the level read, such as a street level view of a 1 km raster, reads the pixel under the center of its bounding box instead, weighted by its own area as a share of that pixel, so
validPixelsis 1 and the area is the zone's, not 0. A part that is not wholly over the raster is left out of that fallback. - A view on a world copy, across the antimeridian (
west > east, oreastpast 180) or a full turn wide is matched to the raster's longitude frame. A polar raster reaches its pole. - A polygon zone takes the same options as a bbox (
band,expression,nodata,level,maxDecodedPixels,maxTiles,maxPixels,signal,pool) and returns the same shape, withwindowAreaM2the ground area of the pixels whose centers fall in the zone, not the polygon's own geodesic area. Positions are[lon, lat]in WGS84 as RFC 7946 requires, reproject a zone in another CRS before the call. Edges are straight in lon/lat and are densified into the file CRS until they sit within a thousandth of a full-resolution pixel of their curve. A pixel counts when its center is inside the zone, the rule GDALgdal_rasterizeand rasterstats use by default. A center exactly on a shared edge belongs to one side only, so adjacent zones (or adjacent bboxes) partition the pixels with none lost or counted twice. Holes are left out by the even-odd rule whatever the ring orientation. Each part of a MultiPolygon is read on its own, so parts that overlap (invalid GeoJSON) count their shared pixels twice, as two reads would. Tiles the polygon does not reach are not fetched, and when the tile budget forces a subsample it is drawn from the tiles the zone touches, so a thin or ring-shaped zone still reads some.maxPixelscounts only the tiles the polygon reaches,maxTilesstill counts its bounding box grid. - A polygon across the antimeridian is read when it is split at 180 (RFC 7946) or written with its longitudes continuous past 180, such as 170 to 190. An edge that jumps more than 180 degrees of longitude, such as 170 to -170, is ambiguous and throws a
RangeError. An edge from -180 to 180 along a parallel, as in a polar cap, is a full turn and is read. A polygon wider than a turn and a non-finite position throw aRangeError, a geometry that is not a Polygon or MultiPolygon aTypeError. - The range is inclusive on both ends, like the GPU filter. Bands with up to 4096 distinct values are exact, continuous bands prorate the edge bins of a 4096-bin table and keep the pixels exactly at the data min and max apart, so a range touching either end (a sea floor at 0) counts them in full. Up to 16 heavy repeated values inside the range of the data, such as a flat lake in a DEM or a fill value, are kept the same way as exact masses (
spikeValues,spikeAreaM2andspikePixelson a binneddistribution), so a range edge on one counts it whole or not at all. A value is kept when it holds more pixels than an average fine bin, heaviest by area first, and a lighter or a 17th value is prorated in its bin. Candidates come from a sample of every 17th pixel, so a value above about 1/65 of the pixels is found in practice but not guaranteed, and a light plateau (a few hundred pixels in a large read) is usually prorated. The sample costs up to about 10 % of a read on a large float band. Keep the result and re-sum it on a range change, nothing is refetched. - Nodata, the mask and non-finite values are skipped.
windowAreaM2andvalidAreaM2give the window and data footprint. Passnodatato override the file's value,expressionto bin a band-math index,binningto plotbandon another histogram's axis. momentsholdssum,estimatedTotal,mean,std(population) andareaWeightedMeanof the valid pixels read, null when none was.sumis the plain sum of the values read at the level read, never scaled.estimatedTotalestimates the full-resolution total inside the bbox. Each value read counts for the level 0 pixels it stands for, the full-resolution pixel count over the read level's pixel count, times the window's pixels over the pixels sampled whencoverageis below 1. The mean and std are never scaled.- Every overview level holds about 4 times fewer pixels than the one below it, so at overview n
sumis about4^ntimes smaller than the full-resolution total, whatever the resampling. On a count raster such as population, readestimatedTotal, notsum. At level 0 withcoverage1 the two are equal and exact. At an overviewestimatedTotalis exact when each overview pixel is the mean of the full-resolution pixels under it (anAVERAGEoverview with no nodata holes) and an estimate otherwise, for example aNEARESToverview or holes averaged away. levelforces a level, 0 is full resolution. It overridesmaxTilesandmaxPixelsand reads every tile of the window at that level. AnAVERAGEoverview also flattens the value tails, so a range area near the band extremes can differ from full resolution. Passlevel: 0for a final number, and readlevelon the result to see which level answered.- A forced
levelfetches every tile the zone reaches in one batch and holds it decoded until the read ends, solevel: 0with anullbbox decodes the whole file into memory, about 400 MB for a 10000 by 10000 float32 band. PassmaxDecodedPixelsas a ceiling, a forced read whose touched tiles hold more pixels than it, counted likemaxPixels, throws aRangeErrorbefore any tile is fetched, so a final read button can fall back to an estimate. It has no default and is ignored withoutlevel, since the budget walk already stays withinmaxPixels. A value that is not a positive number throws aRangeError.scanMosaicWindowStatstakes nolevel, a mosaic scan always reads within the budgets. It rejects a badmaxDecodedPixelsonce before any source opens and otherwise ignores it. windowPercentile(stats, p, weight)reads a percentile from the area distribution, weighted by"area"(default) or"pixels". Bands with up to 4096 distinct values return an exact value, continuous bands prorate within a fine bin and return a heavy repeated value exactly when the share lands on it.computeCogWindowStatsMulti(geotiff, zone, { bands: [1, 2, 3] })fetches and decodes the tiles once and returns aMapof one result per band. An emptybandsreturns an emptyMapwith no request.- A
bandthat is not an integer from 1 togeotiff.countthrows aRangeErrorbefore any tile is fetched, incomputeCogWindowStatsand incomputeCogWindowStatsMulti. So does anexpressionthat reads a band past the count.scanMosaicWindowStatsandcomputeMosaicSeriesStatsreject abandor anexpressionband that is not a positive integer before any source opens. A band past one source's count fails that source, counted infailedHrefsor as afailedentry, never read as data. In a scan under the defaultoverlap: "once"such a source also gives up its claim, since a retry would fail the same way, and the sources under it are read or reread in the ground it held, so the first source down that has the band answers it. Any other failure keeps its claim for a retry. A source first opened this way takes amaxSourcesslot, past the cap it counts inskippedByCap. Ground released onto a source answered from STAC is not counted. K stacked sources that each lack the band cost up to K ownership plans and K read rounds. Progress reportsdoneequal tototalonce, when the scan settles, so treat that as final, and it emits nothing after an abort. isBandRangeError(error)is true for the band error above, aRangeErrornamedBandRangeError. It fails the same way on every retry, so do not re-sign a URL or reopen a file on it. Any otherRangeError, such as a decoder error on a truncated response, may still come from an expired signature.mergeWindowMoments(windows)pools the moments of several reads, such as mosaic sources or zones read one by one.sumandestimatedTotaladd up,meanandstdpool the pixels read with the parallel variance formula,areaWeightedMeanweights each read byvalidAreaM2. Across reads at different levels or coverage the pixel weightedmeanmixes resolutions, so preferareaWeightedMean.scanMosaicWindowStatsreturns it asmoments.scanMosaicWindowStatscounts each ground cell once, for the first source drawn over it. A later source in the array draws over an earlier one, so the scan walks the sources from the last. Each source claims itsfootprint, or itsbboxwhen it has none, minus what the sources above it claimed, and reads its raster inside its bbox andboundsminus those claims. Overlapping MGRS margins and a stack of dates over one place then sum to the ground they cover, not once per source. A source with nothing above it reads exactly as a lone read would. A source fully under the sources above is not opened and counts incovered, and it never takes amaxSourcesslot. A source that fails to open or read keeps its claim, so the ground under it stays unread until a retry instead of falling to a lower source. A bbox-only source claims its whole WGS84 bbox, which for a rotated or projected sheet is larger than its data, so give footprints when sheets overlap. The rule assumes a footprint holds its source's valid pixels, as STAC geometries do. A footprint is read asfindMosaicSourcesAtreads it, so a ring written across the antimeridian under a west-greater-than-east bbox is unwrapped, and a ring past 256 vertices is simplified for the claim (Douglas-Peucker, so corners stay). The plan runs before the reads and yields to the event loop between sources, so an abort during it resolves with nothing read. One source's work is not split, so on a deep pile of overlapping footprints a single step can hold the thread for about 100 ms, and thousands of sources take seconds before the first tile is read. Passoverlap: "perSource"for the old count, every source read whole insidebounds. The progress names every failed source infailedHrefs, an open or a window read. Pass them back asonlyHrefswith the samesourcesandboundsto reread just those, every other source still claims its ground, so the windows merge with the first read and each cell still counts once. Each window carries its sourcehref.scanMosaicWindowStatstakes the samepoolas a single read.stacStats: trueanswers the sources pastmaxSourcesfrom therasterBandshistogram ofband(parse withrasterStats), with no request, so a whole-data read of a collection larger than the cap still counts every item that carries one. A band whose statistics haveminimumequal tomaximumcounts as one value. The item histogram covers the whole item, so its pixels are spread evenly over the share of the footprint the source owns insidebounds.valid_percentcounts the whole raster grid, nodata margins included, so it scales the valid area only for a source claimed by its bbox, a footprint already traces the data. These windows carryfromStac: true, the progress counts them infromStac, theirlevelis 0 by convention and theirmomentsis null, since a histogram count comes from a read of unknown resolution, somomentspools the tile reads only. Their binneddistributionkeeps no heavy value, the spike arrays are empty, since a bucket holds no single value. The cap goes to the sources with no histogram first, nearest the center, and the histograms answer the rest. Sources with no histogram past the cap stay inskippedByCap. The option is ignored with anexpressionor anodataoverride, which the item statistics never saw. Histograms written from a decimated read (rio-stac reads at most 1024 pixels a side by default) carry that read's binning, so treat the result as an estimate.- Only your own
signalcancels.computeCogWindowStatsandcomputeCogWindowStatsMultiresolvenullwhen it aborts, and an AbortError thrown while it is still live (a cached range read cancelled by another caller) rejects like any read error.scanMosaicWindowStatsandscanMosaicStatscount such a source infailedanddone, so the scan always settles.
const quick = await computeCogWindowStats(geotiff, bbox, { signal });
const approxTotal = quick?.moments?.estimatedTotal; // any level, check quick.level
const w = await computeCogWindowStats(geotiff, bbox, { level: 0, signal });
const total = w?.moments?.sum; // exact, level 0 with coverage 1
const median = w && windowPercentile(w, 0.5, "pixels");
const perBand = await computeCogWindowStatsMulti(geotiff, bbox, { bands: [1, 2, 3], signal });EPSG lookups
A COG tagged with a numeric EPSG code needs that CRS definition. EPSG 4326 and 3857 resolve from built-in definitions with no lookup. Any other code goes through one process-wide resolver, which by default fetches https://epsg.io/<code>.json once per code.
That resolver backs the default epsgResolver of COGTileLayer and BandStackLayer and every helper that reprojects, computeCogWindowStats, scanMosaicWindowStats, readCogValueAt, the CRS check in buildBandStack, and the footprint reprojection in loadMosaicSources. An epsgResolver prop on a layer still wins for that layer. It gets the same antimeridian handling as the process-wide resolver, and on COGTileLayer the same per-code memo, so a UTM zone 1 COG renders the same either way, but it only covers that layer. The helpers above keep using the process-wide resolver, so call setEpsgResolver when a pixel read or a window stat must use the same lookup.
For an offline, air-gapped or strict-CSP app, replace it once at startup, before the first layer or helper resolves a CRS. offlineEpsgResolver takes a loader for a table of EPSG code to WKT (1 or 2) or PROJJSON and calls it once, on the first projected lookup, so the table can stay behind a dynamic import.
import { offlineEpsgResolver, setEpsgResolver } from "@carto/ps-cog/cog";
setEpsgResolver(
offlineEpsgResolver(async () => (await import("@developmentseed/epsg/all")).default()),
);@developmentseed/epsg is not a dependency of this package. Install it at the version of the rest of the @developmentseed train. Its table is a gzip CSV of every EPSG code that it fetches from your own origin, so serve .csv.gz as an asset (or pass its URL to the loader). An unknown code rejects with a clear error, which reaches deck onError for a layer and makes a pixel read resolve null. Lookups are memoized per code and keep the antimeridian handling. Any EpsgResolver, (epsg: number) => Promise<ProjectionDefinition>, works too, for example a proxy on your own server. Call setEpsgResolver() with no argument to restore the epsg.io default.
Private buckets
The library holds no endpoint and no token. You implement UrlProvider against your signer.
import { createSignedUrlCache, type UrlProvider } from "@carto/ps-cog/signed-url";
import { resolveGcsUrl } from "@carto/ps-cog/url";
const urlProvider: UrlProvider = {
sign: (key, o) => mySigner.sign(key, o), // → { url, expiresAt }
signBatch: (keys, o) => mySigner.signBatch(keys, o), // → [{ key, url, expiresAt, error }]
};
const cache = createSignedUrlCache({ urlProvider });
const resolveUrl = async (href: string) =>
isPrivate(href) ? (await cache.resolve(href)).url : resolveGcsUrl(href);expiresAtis the epoch in ms issued by th
