npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@carto/ps-cog

v0.8.0

Published

Framework-agnostic client library for rendering cloud-native rasters in the CARTO ecosystem.

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/proj

Install 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 lerc build takes the Node code path and throws module.require is not implemented yet, so the map sends every lerc range in the graph to the jsdelivr ES build. The library then asks for lerc-wasm.wasm beside its esm.sh chunk, where none exists, so configureLercWasm points it at the wasm of the same lerc version. Either fix alone fails with both async and sync fetching of the wasm failed or the module.require error.
  • One instance of @luma.gl/core, @deck.gl/core and proj4 per page. Two luma.gl copies break the shader module ABI, two deck.gl copies mix layer classes from two builds, and two proj4 registries split the definitions the library writes from the ones deck.gl-geotiff reads, so the COG renders black. Import Deck, and proj4 if the page uses it, from the exact URLs the map pins to, never from a second CDN or another version. jsdelivr +esm builds 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-cog declare ^ 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 its lzw-tiff-decoder) and @cogeotiff/core, plus proj4, lerc and wkt-parser. proj4 asks for wkt-parser@^1.5.5 while @developmentseed/proj imports the exact version, so an unpinned range loads it twice. List the ranges one module imports with curl -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 DecoderPool decodes 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,
});
  • ensure returns the same TileLoader object while its bands cover the need, so getTileData stays stable and the id stays the bare baseId for any file with at most four bands.
  • A band outside 1..bandCount is dropped, and a need with no valid band falls back to defaults, so a bad control value never yields a bandless tile.
  • CogBandPlan wraps 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 })), paint plan.current, then on every control change pass plan.select(neededBandsInput, categorical) to the layer as id and getTileData. 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 /params offers every band until the formula reads four distinct bands, then only those. formulaBands lists the bands a formula reads. appendFormulaToken, removeFormulaToken and cycleFormulaOp edit a chain, and normalizeFormula gives what to write to expr, or null to delete it.
  • When your layer id is fixed by something else (a layer store that sets id to its own key), keep the id and pass updateTriggers: { getTileData: bandSetKey(loader.bands) } instead. COGTileLayer and BandStackLayer (whose band set is bandIndexes) turn a change of that trigger into a fresh tileset under the same id, 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. BandStackLayer also refetches when bandIndexes changes 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 new id. Changing getTileData or bandIndexes with neither a new id nor 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 deck onError routing.

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 it

A 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, windowToSelectedBars highlights the current window and selectedBarsToWindow turns brushed bars into a rescaleOverride.
  • Metadata metadataRows with a stable key per 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 isAbortError for the error an aborted read throws. It also matches an error whose cause chain holds the AbortError, such as the SourceError a cancelled range fetch rejects with, so check it rather than err.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 resolveCogRendering

Use 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, openCog one source for stats and resolveCogRendering, then pass renderSource: buildMosaicRenderSource({ layerIdPrefix, getTileData, renderTile, opacity }).
  • Sublayer ids are <layerIdPrefix>-<source id or href>. Keep the prefix stable like a layer id. 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 list loadMosaicSources and parseStacMosaicSources drop it from every source that shares it and those sublayers use the href. Dedupe your own sources the same way if you build them by hand.
  • Hide a mosaic with visible: false and keep it in the layer list, its tile caches survive and showing it again refetches nothing. visible and opacity set on the mosaic reach every per-source layer, pickable does not. A mosaic left at the deck default keeps the value renderSource set per source.
  • A GeoJSON tile index or VRT in a projected CRS gets each source's WGS84 bbox from 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 match findMosaicSourcesAt and 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 has west below -180, a zone 60 tile east past 180, and an EPSG:3413 tile at 150E sits near -210. A tile on the seam of that frame stays one span. findMosaicSourcesAt, filterMosaicSourcesByBounds, rankMosaicSourcesByCenter and the MosaicTileLayer view search all match such a bbox at its real longitude.
  • Every layer draws on the main world. COGTileLayer, BandStackLayer and each MosaicTileLayer source 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 (MapView with repeat) or a globe draws the copies itself, and the layer then draws one. onGeoTIFFLoad reports geographicBounds moved 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 sources array to change the source set. Rendering, filterMosaicSourcesByBounds and rankMosaicSourcesByCenter place a source by its bbox only.
  • 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. MosaicHomogeneityCheck finds 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_FIELDS gives the field order, MOSAIC_HOMOGENEITY_FIELD_LABELS a label and MOSAIC_HOMOGENEITY_FIELD_EFFECTS the effect above as one sentence per field. The CRS is not compared, each source reprojects on its own. readMosaicSourceProfile and compareMosaicSourceProfiles are 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 findMosaicSourcesAt and read it with readCogValueAt.
  • A STAC source always carries its datetime, or start_datetime when datetime is null. Ask for more with loadMosaicSources(url, { resolveUrl, stacMetadata: { properties: ["eo:cloud_cover"], footprint: true } }), the same options go to parseStacItemSource and parseStacMosaicSources. Only listed keys the item has land in properties. rasterStats: true keeps the COG asset's band statistics and histogram as rasterBands (StacRasterBand[], index 0 is band 1), read from the asset's raster:bands (raster extension 1.x) or bands (STAC 1.1, raster:histogram), then the item's properties.bands. A malformed histogram is dropped.
  • With a footprint (the item's Polygon or MultiPolygon geometry), findMosaicSourcesAt tests 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 findMosaicSourcesAt and filterMosaicSourcesByBounds. A footprint ring is read as written unless the bbox is full width (-180 to 180) or crosses the antimeridian. Then an edge longer than 180 degrees goes the short way around (an unsplit crossing ring), an edge from -180 to 180 stays a full-width edge (a world or polar cap box, 1e-9 of 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 -180 and 180, a ring edge from -179.99 to 179.99 reads as a sliver across the antimeridian.
  • To also scan stats or read pixels, pass a getSource that caches one GeoTIFF per 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 footprint when set, holds the point, sorted by datetime ascending. Sources sharing a datetime keep their array order.
  • A source with no datetime, or one Date.parse cannot read, comes after every dated entry in array order with time: null. Pass undated: "skip" to drop them. A from or to bound (ISO string, epoch ms or Date, both inclusive) always drops them. A date-only to such as "2024-12-31" covers that whole UTC day, a date-only from starts at its UTC midnight, and a date-time with no offset is read in local time, so give it a Z or an offset.
  • Each entry carries source, id, href, datetime, time (epoch ms), status, values and scaledValues (one per requested band, in bands order, every band of the file when bands is omitted), nodataPerBand, nodata and level. A band the file lacks reads NaN and counts as nodata. nodata is true when every requested band is nodata or masked.
  • status is ok, outside (in the bbox or footprint but off the image) or failed. A failed open or read stays on its entry with error set and is counted in failed, it never fails the call. When every source read fails the call rejects with an AggregateError holding each error.
  • openSource(source, signal) resolves a GeoTIFF or null (a failure), like the scans. Pass the opener that keeps one GeoTIFF per href and opens with a background getPriority, 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's signal, 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.
  • expression takes a BandExpression (from parseBandExpression) and sets value on each entry, the band math on the raw values of the bands it reads, the arithmetic the stats paths use. Its bands are read whatever bands lists. value is null without an expression, on an entry that is not ok, 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.
  • level and resolution work as in readCogValueAt, per source. nodata and pool pass 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 about concurrency decoded tiles and nothing stays pinned on the open files afterwards. A long series downloads one tile per source, so cap it with maxSources, which keeps the newest dated sources (then undated ones) and never opens the rest.
  • matched, skippedByRange, skippedUndated, skippedByCap and failed on 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 RangeError before any open, a non positive concurrency or band, a negative maxSources, an unparseable from or to, or a from after to.

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 by datetime like the point series. The zone is a bbox, a Polygon, a MultiPolygon or a Feature wrapping one, or null to read every source whole.
  • from, to, undated, maxSources, concurrency (4 by default) and the abort rule are those of readMosaicSeriesAt, and openSource gets the signal the same way. The window options band, expression, binning, nodata, maxTiles, maxPixels and pool pass through to every read. level is 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, status and stats, the full CogWindowStats of that source in the zone. status is ok, outside (the bbox meets the zone but the image does not, or the file's WGS84 bounds cannot be resolved, stats.level is -1) or failed with error set and stats null.
  • A footprint is not tested, a source whose image holds no valid pixel in the zone is an ok entry with validPixels 0 and moments null.
  • The zone and the options are validated before any open. A polygon zone throws a TypeError or a RangeError as computeCogWindowStats does, a bbox zone with a non-finite edge or south above north throws a RangeError, and so do an expression over more than 4 distinct bands and a band that is not a positive integer. scanMosaicWindowStats checks a bbox zone the same way. When every source read fails the call rejects with an AggregateError.
  • Memory is about concurrency window reads, each bounded by maxTiles and maxPixels. Cap a long series with maxSources.

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 * bands bytes, so a single point on a 1024 px, four band uint16 tile 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 signal counts as a waiter until the fetch settles, so eviction never cancels it.
  • For hover on large tiles pass level (0 full resolution, n for overviews[n - 1]) to read an overview, and read level 0 on click. An index past the last overview reads the coarsest one and a negative index reads full resolution.
  • Or pass resolution in 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 a RangeError. level wins when both are set. sample.level reports the level read.
  • readCogValuesAt projects every point, groups the points by tile, reuses tiles already cached and fetches the rest in groups of tilesPerFetch tiles (16 by default), one fetchTiles call 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, null for a point outside the image or one that cannot be projected, with the same sample shape and nodata flags as readCogValueAt.
  • A batch samples each group and drops it before fetching the next, so peak memory is about tilesPerFetch decoded tiles (about 32 MB for 16 Sentinel-2 L2A 1024 px uint16 tiles) whatever the number of points. A larger tilesPerFetch means 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 band i + 1 equals 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 nodata so the readout matches the map, a number replaces the file value and null stands for nodataOverride: "off", which drops the nodata value and the NaN rule but keeps the mask. Under null a NaN sample reads as a value, the way the render draws it, while the window stats still skip every non-finite value. readBandStackValueAt applies it to every file.
  • With resolution each file of a band stack picks its own level, so 10 m and 20 m bands each read at their own scale. level, row, col and crsCoordinate on 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.
  • nodata is true when the mask marks the pixel empty or every band is nodata, the rasterio dataset_mask rule. One band at nodata does not hide the others, so test nodataPerBand for the band you show.
  • formatCogReadout applies 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 null as the bbox to read the whole raster. The whole-file read uses the same area math, so prefer it over estimateRangeAreaKm2 when accuracy matters (it corrects nodata, feet, Web Mercator and latitude bias).
  • computeCogWindowStats picks a level coarse to fine. It starts at the coarsest overview and refines one level at a time while the finer level still fits maxTiles (default DEFAULT_WINDOW_MAX_TILES, tiles of the window's bounding grid) and maxPixels (default DEFAULT_WINDOW_MAX_PIXELS, 4 * 2 ** 20, 4 tiles of 1024 px or 1 of 2048 px), and stops at the first level that overflows. maxPixels counts 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 than maxPixels still reads the finest level where the zone sits in one tile. A level inside both budgets reads every tile the zone reaches, coverage 1. When even the coarsest level overflows (a file with no overview pyramid), it gets an even tile subsample inside both budgets and a coverage below 1, which the area scales back up.
  • A window read on the GeoTIFF you render shares its request queue and priority, so a large read can wait alongside visible tiles. Open a second GeoTIFF for the same file with openCog(url, { getPriority }) returning a larger number than the layer's render reads, such as Number.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 validPixels is 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, or east past 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, with windowAreaM2 the 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 GDAL gdal_rasterize and 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. maxPixels counts only the tiles the polygon reaches, maxTiles still 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 a RangeError, a geometry that is not a Polygon or MultiPolygon a TypeError.
  • 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, spikeAreaM2 and spikePixels on a binned distribution), 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. windowAreaM2 and validAreaM2 give the window and data footprint. Pass nodata to override the file's value, expression to bin a band-math index, binning to plot band on another histogram's axis.
  • moments holds sum, estimatedTotal, mean, std (population) and areaWeightedMean of the valid pixels read, null when none was. sum is the plain sum of the values read at the level read, never scaled. estimatedTotal estimates 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 when coverage is 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 sum is about 4^n times smaller than the full-resolution total, whatever the resampling. On a count raster such as population, read estimatedTotal, not sum. At level 0 with coverage 1 the two are equal and exact. At an overview estimatedTotal is exact when each overview pixel is the mean of the full-resolution pixels under it (an AVERAGE overview with no nodata holes) and an estimate otherwise, for example a NEAREST overview or holes averaged away.
  • level forces a level, 0 is full resolution. It overrides maxTiles and maxPixels and reads every tile of the window at that level. An AVERAGE overview also flattens the value tails, so a range area near the band extremes can differ from full resolution. Pass level: 0 for a final number, and read level on the result to see which level answered.
  • A forced level fetches every tile the zone reaches in one batch and holds it decoded until the read ends, so level: 0 with a null bbox decodes the whole file into memory, about 400 MB for a 10000 by 10000 float32 band. Pass maxDecodedPixels as a ceiling, a forced read whose touched tiles hold more pixels than it, counted like maxPixels, throws a RangeError before any tile is fetched, so a final read button can fall back to an estimate. It has no default and is ignored without level, since the budget walk already stays within maxPixels. A value that is not a positive number throws a RangeError. scanMosaicWindowStats takes no level, a mosaic scan always reads within the budgets. It rejects a bad maxDecodedPixels once 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 a Map of one result per band. An empty bands returns an empty Map with no request.
  • A band that is not an integer from 1 to geotiff.count throws a RangeError before any tile is fetched, in computeCogWindowStats and in computeCogWindowStatsMulti. So does an expression that reads a band past the count. scanMosaicWindowStats and computeMosaicSeriesStats reject a band or an expression band that is not a positive integer before any source opens. A band past one source's count fails that source, counted in failedHrefs or as a failed entry, never read as data. In a scan under the default overlap: "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 a maxSources slot, past the cap it counts in skippedByCap. 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 reports done equal to total once, 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, a RangeError named BandRangeError. It fails the same way on every retry, so do not re-sign a URL or reopen a file on it. Any other RangeError, 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. sum and estimatedTotal add up, mean and std pool the pixels read with the parallel variance formula, areaWeightedMean weights each read by validAreaM2. Across reads at different levels or coverage the pixel weighted mean mixes resolutions, so prefer areaWeightedMean. scanMosaicWindowStats returns it as moments.
  • scanMosaicWindowStats counts 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 its footprint, or its bbox when it has none, minus what the sources above it claimed, and reads its raster inside its bbox and bounds minus 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 in covered, and it never takes a maxSources slot. 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 as findMosaicSourcesAt reads 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. Pass overlap: "perSource" for the old count, every source read whole inside bounds. The progress names every failed source in failedHrefs, an open or a window read. Pass them back as onlyHrefs with the same sources and bounds to 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 source href. scanMosaicWindowStats takes the same pool as a single read.
  • stacStats: true answers the sources past maxSources from the rasterBands histogram of band (parse with rasterStats), 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 have minimum equal to maximum counts 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 inside bounds. valid_percent counts 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 carry fromStac: true, the progress counts them in fromStac, their level is 0 by convention and their moments is null, since a histogram count comes from a read of unknown resolution, so moments pools the tile reads only. Their binned distribution keeps 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 in skippedByCap. The option is ignored with an expression or a nodata override, 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 signal cancels. computeCogWindowStats and computeCogWindowStatsMulti resolve null when it aborts, and an AbortError thrown while it is still live (a cached range read cancelled by another caller) rejects like any read error. scanMosaicWindowStats and scanMosaicStats count such a source in failed and done, 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);
  • expiresAt is the epoch in ms issued by th