cog-tiler-wasm 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 OpenGeos
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,237 @@
1
+ # cog-tiler-wasm
2
+
3
+ [![CI](https://github.com/opengeos/cog-tiler-wasm/actions/workflows/ci.yml/badge.svg)](https://github.com/opengeos/cog-tiler-wasm/actions/workflows/ci.yml)
4
+ [![Live demo](https://img.shields.io/badge/demo-GitHub%20Pages-blue)](https://opengeos.github.io/cog-tiler-wasm/)
5
+
6
+ **Serverless, TiTiler-style XYZ tiling of Cloud Optimized GeoTIFFs, in
7
+ WebAssembly.** No backend, no GDAL, no PROJ - the map fetches COG byte ranges
8
+ directly and synthesizes `z/x/y` tiles client-side.
9
+
10
+ **[Live demo](https://opengeos.github.io/cog-tiler-wasm/)** - loads a sample
11
+ EPSG:3857 COG over HTTP range requests and renders it on a MapLibre map, all in
12
+ the browser. Paste any CORS- and range-enabled 3857 COG URL to try your own.
13
+
14
+ This crate is the **tiling brain**. It does the slippy-map math (tile -> source
15
+ pixel window), picks the right COG overview level, and renders a decoded window
16
+ into an RGBA tile (rescale + colormap + nodata alpha). It deliberately does
17
+ **not** parse COGs or do network I/O - that is delegated to
18
+ [`whitebox-wasm`](https://github.com/opengeos/whitebox-wasm)'s `CogStream`,
19
+ which already implements a pure-Rust codec stack (Deflate/LZW/JPEG/WebP/…) and
20
+ HTTP range streaming. The two compose into a complete tiler:
21
+
22
+ ```
23
+ CogStream (whitebox-wasm) CogTiler (this crate)
24
+ ───────────────────────── ─────────────────────
25
+ geo_transform(), levels_json() ──▶ new(gt, w, h, epsg, nodata, levels)
26
+ pixel_window_for_tile(z,x,y) ─▶ {level,x,y,w,h}
27
+ tiles_for_window(level,x,y,w,h) ◀── (JS fetches byte ranges, decodes tiles)
28
+ decode_tile_f64(...) ──▶ render(window, w, h, min, max, cmap) ─▶ RGBA
29
+ ```
30
+
31
+ The result is a TiTiler-class viewer with **zero hosting cost**: wire it to a
32
+ MapLibre custom protocol and the browser does everything.
33
+
34
+ ## Why this is feasible without a server
35
+
36
+ A dynamic tile server (TiTiler = FastAPI + rio-tiler + GDAL) does five things:
37
+ read a COG by HTTP **range request**, decode the relevant internal tiles,
38
+ resample to the requested XYZ tile, apply rescale/colormap/nodata, and encode.
39
+ Every one of those has a pure-Rust, WASM-clean implementation today - the two
40
+ historically hard parts (a C-free codec stack and CRS handling) are already
41
+ solved in `whitebox-wasm`. This crate adds the thin layer on top: mercator
42
+ addressing, overview selection, resampling, and rendering.
43
+
44
+ ## Status
45
+
46
+ **v1 (this scaffold):** EPSG:3857 sources, single-band rendering, built-in
47
+ colormaps, MapLibre demo. The crate builds to `wasm32-unknown-unknown` and ships
48
+ a `wasm-pack` web package.
49
+
50
+ See [Roadmap](#roadmap) for warping, multi-band, and edge/WASI serving.
51
+
52
+ ## Build
53
+
54
+ ```bash
55
+ rustup target add wasm32-unknown-unknown
56
+ cargo install wasm-pack
57
+ wasm-pack build crates/cog-tiler-wasm --release --target web --out-dir pkg
58
+ ```
59
+
60
+ ### Run the demo locally
61
+
62
+ ```bash
63
+ npm run dev # builds the wasm, assembles demo/, serves http://localhost:8000/
64
+ ```
65
+
66
+ `npm run dev` builds the wasm into `demo/`, copies in `cog-tiler.js` + the sample,
67
+ and starts a zero-dependency static server with **HTTP range support** (which the
68
+ tile streaming needs - the stdlib `python -m http.server` does not do ranges).
69
+ Set `PORT` to change the port. No `npm install` is required (the dev scripts use
70
+ only Node built-ins; the demo loads its peer deps from a CDN via an import map).
71
+
72
+ The published [GitHub Pages demo](https://opengeos.github.io/cog-tiler-wasm/) is
73
+ built the same way by `.github/workflows/pages.yml`.
74
+
75
+ ## Usage (reusable module)
76
+
77
+ [`cog-tiler.js`](cog-tiler.js) is the package's main entry. It wraps the wasm
78
+ tiler + `whitebox-wasm` and handles EPSG:3857 sources, on-the-fly **warping** of
79
+ any projected/4326 COG to Web Mercator, and **paletted (categorical)** rendering -
80
+ so apps import it instead of copying the demo.
81
+
82
+ It ships **inside the npm package** (`main`/`module` -> `cog-tiler.js`); the raw
83
+ wasm tiler is also available at the `cog-tiler-wasm/wasm` subpath. Install it
84
+ alongside its peer dependencies:
85
+
86
+ ```bash
87
+ npm install cog-tiler-wasm whitebox-wasm proj4 geotiff geotiff-geokeys-to-proj4 maplibre-gl
88
+ ```
89
+
90
+ ```js
91
+ import maplibregl from "maplibre-gl";
92
+ import { init, openCog, registerCogProtocol } from "cog-tiler-wasm";
93
+
94
+ await init(); // load the wasm modules once
95
+
96
+ let source = null;
97
+ // The protocol resolves the active source + render settings per tile.
98
+ registerCogProtocol(maplibregl, "cog", () => ({
99
+ source,
100
+ render: { min: 0, max: 3000, colormap: "viridis" }, // ignored for paletted COGs
101
+ }));
102
+
103
+ source = await openCog(url); // EPSG:3857 fast path, or warped if projected/4326
104
+ // openCog also accepts a local raster: a File (e.g. from <input type=file>),
105
+ // Blob, ArrayBuffer, or Uint8Array - read in memory, no server needed.
106
+ map.addSource("cog", { type: "raster", tiles: ["cog://{z}/{x}/{y}"], tileSize: 256 });
107
+ map.addLayer({ id: "cog", type: "raster", source: "cog" });
108
+
109
+ // source.crsLabel, source.levels, source.hasPalette, source.boundsLonLat
110
+ // and source.renderTileRGBA(z, x, y, render) / renderTilePNG(...) are also exposed.
111
+ ```
112
+
113
+ ### TiTiler-style COG API
114
+
115
+ `CogSource` mirrors the read endpoints of
116
+ [TiTiler's COG API](https://developmentseed.org/titiler/endpoints/cog/),
117
+ client-side (works on projected/paletted sources too, via the warp path):
118
+
119
+ ```js
120
+ // Metadata / read
121
+ src.info(); // /cog/info -> bounds, count, dtype, nodata, overviews, min/maxzoom, ...
122
+ src.infoGeoJSON(); // /cog/info.geojson -> GeoJSON Feature (bbox polygon + info)
123
+ src.tilejson(); // /cog/tilejson.json -> Mapbox TileJSON document
124
+ await src.point(lon, lat); // /cog/point -> band value(s) at a WGS84 coordinate
125
+ await src.statistics({ maxSize }); // /cog/statistics -> per-band min/max/mean/std/count/
126
+ // valid_percent/median/percentiles/histogram (from an overview)
127
+
128
+ // Image generation (all accept render params below)
129
+ await src.previewPNG({ maxSize: 1024 }); // /cog/preview -> PNG bytes
130
+ await src.bboxPNG([minLon, minLat, maxLon, maxLat]); // /cog/bbox -> PNG bytes
131
+ await src.preview(opts); // -> { width, height, rgba } (bbox() likewise)
132
+
133
+ // Render params (on tiles, preview, bbox):
134
+ // bidx: 1-based bands; one -> colormap/palette, three -> RGB composite
135
+ // rescale: [[min,max], ...] per band, or [min,max]; or min/max shorthand
136
+ // colormap: one of colormaps() (viridis, magma, plasma, inferno, cividis,
137
+ // turbo, terrain, blues, greens, reds, rdylgn, spectral, gray)
138
+ // reversed: sample the colormap back-to-front (single-band)
139
+ // stretch: transfer curve "linear" | "sqrt" | "log" (applied after rescale)
140
+ // gamma: power-law gamma (1 = off; applied after the stretch)
141
+ // nodata: override the transparency value
142
+ // opacity: output alpha multiplier 0..1
143
+ await src.renderTilePNG(z, x, y, { bidx: [4, 3, 2], rescale: [0, 3000] }); // false-color RGB
144
+ import { colormaps } from "cog-tiler-wasm";
145
+ ```
146
+
147
+ The render pipeline (nodata -> rescale -> stretch -> gamma -> colormap, plus
148
+ reverse/opacity) matches the controls of GPU raster viewers such as
149
+ [maplibre-gl-raster](https://github.com/opengeos/maplibre-gl-raster), so
150
+ `cog-tiler-wasm` can serve as a CPU/WASM rendering backend for the same UI - the
151
+ panel (band, rescale, colormap, curve, gamma, opacity) maps directly onto these
152
+ params and re-renders by re-requesting tiles.
153
+
154
+ Server-only endpoints (`/map.html`, `WMTSCapabilities.xml`, `/validate`,
155
+ `/stac`) are out of scope for a client-side library; band-math `expression` is
156
+ on the [roadmap](#roadmap).
157
+
158
+ For a no-build page, map the peer deps with an import map (see
159
+ [`demo/index.html`](demo/index.html)):
160
+
161
+ ```html
162
+ <script type="importmap">
163
+ { "imports": {
164
+ "whitebox-wasm": "https://esm.sh/whitebox-wasm@0.4.0",
165
+ "proj4": "https://esm.sh/proj4@2.20.9",
166
+ "geotiff": "https://esm.sh/geotiff@2.1.3",
167
+ "geotiff-geokeys-to-proj4": "https://esm.sh/geotiff-geokeys-to-proj4@2024.4.13"
168
+ } }
169
+ </script>
170
+ ```
171
+
172
+ ### Low-level Rust API
173
+
174
+ The wasm crate (`CogTiler`) is the 3857 tiling brain underneath `cog-tiler.js`:
175
+ `pixel_window_for_tile(z, x, y)` maps a tile to a source-pixel window/overview,
176
+ and `render(window, w, h, min, max, colormap, nodata_alpha)` rasterizes an
177
+ assembled f64 window to RGBA. See [`cog-tiler.js`](cog-tiler.js) for the
178
+ window-assembly and warp loops built on top.
179
+
180
+ A runnable MapLibre example (custom `cog://` protocol) is in
181
+ [`demo/index.html`](demo/index.html).
182
+
183
+ ## API
184
+
185
+ `version()`, `tile_bounds_3857(z, x, y) -> [minx,miny,maxx,maxy]`.
186
+
187
+ **`new CogTiler(geo_transform, width, height, epsg, nodata, levels_json)`**
188
+ - `geo_transform` - 6-element GDAL affine of the full-res raster (`Float64Array`)
189
+ - `width`/`height` - full-resolution pixel dimensions
190
+ - `epsg` - source CRS; must be `3857` in v1
191
+ - `nodata` - optional nodata value (`undefined`/`NaN` = none)
192
+ - `levels_json` - JSON array of level descriptors, finest level first; only
193
+ `width`/`height` are read (extra `whitebox-wasm` fields are ignored)
194
+
195
+ Properties: `epsg`, `num_levels`.
196
+
197
+ **`pixel_window_for_tile(z, x, y)`** -> `{ level, x, y, w, h, level_width, level_height, empty }`
198
+ The overview level and pixel window covering the tile. `empty` is true when the
199
+ tile lies outside the raster.
200
+
201
+ **`render(pixels, win_w, win_h, min, max, colormap, nodata_alpha)`** -> `Uint8Array`
202
+ A `256*256*4` RGBA tile. `pixels` is the decoded row-major `f64` window;
203
+ `colormap` is `"viridis" | "magma" | "terrain" | "gray"`. Empty windows render
204
+ fully transparent.
205
+
206
+ ## Roadmap
207
+
208
+ - **TiTiler COG API parity** - done: `info`, `info.geojson`, `tilejson`,
209
+ `point`, `statistics`, `preview`, `bbox`, `bidx`/RGB band selection, and 13
210
+ colormaps (see [above](#titiler-style-cog-api)). Next: band-math
211
+ **`expression`** and `color_formula`.
212
+ - **Warping** of projected/4326 sources and **paletted/categorical** rendering
213
+ are done in [`cog-tiler.js`](cog-tiler.js) (proj4js + geotiff.js). Next: expose
214
+ the source proj string + color table **upstream in `whitebox-wasm`** (it
215
+ already parses both) to drop the geotiff.js dependency, then move the warp into
216
+ the Rust crate (`proj4rs`).
217
+ - **Edge / WASI serving** - run the same module as a serverless XYZ endpoint
218
+ near the data, not only in the browser.
219
+ - **STAC / mosaics** - multi-asset orchestration.
220
+
221
+ ## Releasing
222
+
223
+ The npm package bundles the wasm tiler **and** the `cog-tiler.js` module
224
+ (assembled by [`scripts/prepare-pkg.mjs`](scripts/prepare-pkg.mjs)). To cut a
225
+ release, push a `vX.Y.Z` tag; [`release.yml`](.github/workflows/release.yml)
226
+ builds, assembles, and publishes to npm via Trusted Publishing (OIDC, no token):
227
+
228
+ ```bash
229
+ git tag v0.2.0 && git push origin v0.2.0
230
+ ```
231
+
232
+ One-time setup: configure the package's Trusted Publisher on npmjs.com (package
233
+ -> Settings -> Trusted Publisher) to this repo + `release.yml`.
234
+
235
+ ## License
236
+
237
+ [MIT](LICENSE) © OpenGeos.
package/cog-tiler.d.ts CHANGED
@@ -8,8 +8,31 @@ export interface RenderOptions {
8
8
  min?: number;
9
9
  /** High end of the rescale range (continuous bands). Default 1. */
10
10
  max?: number;
11
- /** Colormap name: "viridis" | "magma" | "terrain" | "gray". Default "viridis". */
11
+ /** Per-band rescale `[[min,max], ...]`, or a single `[min,max]`. Overrides min/max. */
12
+ rescale?: [number, number][] | [number, number];
13
+ /** Colormap name (single-band). See {@link colormaps}. Default "viridis". */
12
14
  colormap?: string;
15
+ /** 1-based band indices. One band -> palette/colormap; three -> RGB composite.
16
+ * Default: all-bands RGB if the source has >= 3 bands, else band 1. */
17
+ bidx?: number[];
18
+ /** Override the source nodata value for transparency. */
19
+ nodata?: number;
20
+ /** Transfer curve applied to the rescaled value before the colormap.
21
+ * Default "linear". */
22
+ stretch?: "linear" | "sqrt" | "log";
23
+ /** Power-law gamma (1 = off). Applied after the stretch. */
24
+ gamma?: number;
25
+ /** Sample the colormap back-to-front (single-band). */
26
+ reversed?: boolean;
27
+ /** Output alpha multiplier in [0,1]. Default 1. */
28
+ opacity?: number;
29
+ }
30
+
31
+ /** A rendered image: row-major RGBA, `width * height * 4` bytes. */
32
+ export interface RenderedImage {
33
+ width: number;
34
+ height: number;
35
+ rgba: Uint8ClampedArray;
13
36
  }
14
37
 
15
38
  /** A level descriptor from `CogStream.levels_json()`. */
@@ -39,20 +62,80 @@ export declare class CogSource {
39
62
  readonly boundsLonLat: number[];
40
63
  /** True when the band is paletted (categorical) and rendered via its table. */
41
64
  readonly hasPalette: boolean;
42
- /** Render an XYZ tile to a 256x256 RGBA buffer, or null if empty. */
43
- renderTileRGBA(z: number, x: number, y: number, opts?: RenderOptions): Promise<Uint8ClampedArray | null>;
65
+ /** Render an XYZ tile to a 256x256 RGBA buffer, or null if empty. (Paletted
66
+ * tiles are a `Uint8ClampedArray`; continuous tiles are the wasm `render()`
67
+ * `Uint8Array`.) */
68
+ renderTileRGBA(
69
+ z: number,
70
+ x: number,
71
+ y: number,
72
+ opts?: RenderOptions,
73
+ ): Promise<Uint8Array | Uint8ClampedArray | null>;
44
74
  /** Render an XYZ tile to PNG bytes (empty Uint8Array for a blank tile). */
45
75
  renderTilePNG(z: number, x: number, y: number, opts?: RenderOptions): Promise<Uint8Array>;
76
+
77
+ // TiTiler-style read API.
78
+
79
+ /** Dataset info (bounds, bands, dtype, nodata, overviews, min/maxzoom, ...). */
80
+ info(): Record<string, unknown>;
81
+ /** Dataset info as a GeoJSON Feature (bbox polygon + info properties). */
82
+ infoGeoJSON(): Record<string, unknown>;
83
+ /** Mapbox TileJSON document. */
84
+ tilejson(opts?: {
85
+ tilesUrl?: string;
86
+ minzoom?: number;
87
+ maxzoom?: number;
88
+ scheme?: string;
89
+ }): Record<string, unknown>;
90
+ /** Band value(s) at a WGS84 lon/lat. `bidx` is 1-based; default all bands. */
91
+ point(
92
+ lon: number,
93
+ lat: number,
94
+ opts?: { bidx?: number[] },
95
+ ): Promise<{ coordinates: [number, number]; values: number[]; band_names: string[]; outside?: boolean }>;
96
+ /** Per-band statistics from a decimated overview (≤ `maxSize` px wide). */
97
+ statistics(opts?: { maxSize?: number }): Promise<Record<string, Record<string, unknown>>>;
98
+
99
+ /** Render a preview of the whole dataset (≈ `/cog/preview`). */
100
+ preview(
101
+ opts?: RenderOptions & { maxSize?: number; width?: number; height?: number },
102
+ ): Promise<RenderedImage>;
103
+ /** Render a WGS84 bbox `[minLon, minLat, maxLon, maxLat]` region (≈ `/cog/bbox`). */
104
+ bbox(
105
+ bbox: [number, number, number, number],
106
+ opts?: RenderOptions & { maxSize?: number; width?: number; height?: number },
107
+ ): Promise<RenderedImage>;
108
+ /** {@link preview} encoded as PNG bytes. */
109
+ previewPNG(
110
+ opts?: RenderOptions & { maxSize?: number; width?: number; height?: number },
111
+ ): Promise<Uint8Array>;
112
+ /** {@link bbox} encoded as PNG bytes. */
113
+ bboxPNG(
114
+ bbox: [number, number, number, number],
115
+ opts?: RenderOptions & { maxSize?: number; width?: number; height?: number },
116
+ ): Promise<Uint8Array>;
46
117
  }
47
118
 
119
+ /** Names of the built-in single-band colormaps. */
120
+ export declare function colormaps(): string[];
121
+
48
122
  /** Initialize the wasm modules (idempotent). Resolve before `openCog`. */
49
123
  export declare function init(): Promise<unknown>;
50
124
 
51
- /** Open a COG and return a {@link CogSource} ready to render XYZ tiles. */
52
- export declare function openCog(url: string): Promise<CogSource>;
125
+ /**
126
+ * Open a COG and return a {@link CogSource} ready to render XYZ tiles. Pass a URL
127
+ * string (read via HTTP range) or in-memory bytes / a Blob / a File for a local
128
+ * raster.
129
+ */
130
+ export declare function openCog(source: string | ArrayBuffer | Uint8Array | Blob): Promise<CogSource>;
53
131
 
54
- /** Encode a 256x256 RGBA buffer to PNG bytes (browser; uses OffscreenCanvas). */
55
- export declare function rgbaToPng(rgba: Uint8Array | Uint8ClampedArray): Promise<Uint8Array>;
132
+ /** Encode an RGBA buffer to PNG bytes (browser; uses OffscreenCanvas).
133
+ * Defaults to 256x256 when `width`/`height` are omitted. */
134
+ export declare function rgbaToPng(
135
+ rgba: Uint8Array | Uint8ClampedArray,
136
+ width?: number,
137
+ height?: number,
138
+ ): Promise<Uint8Array>;
56
139
 
57
140
  /** Minimal shape of the maplibre-gl module needed to register a protocol. */
58
141
  export interface MapLibreLike {
package/cog-tiler.js CHANGED
@@ -21,7 +21,7 @@
21
21
  * map.addSource("cog", { type: "raster", tiles: ["cog://{z}/{x}/{y}"], tileSize: 256 });
22
22
  */
23
23
  import initWhitebox, { CogStream } from "whitebox-wasm";
24
- import initTiler, { CogTiler } from "./cog_tiler_wasm.js";
24
+ import initTiler, { colorize, colormap_names } from "./cog_tiler_wasm.js";
25
25
  import proj4 from "proj4";
26
26
  import * as GeoTIFF from "geotiff";
27
27
  import geokeysToProj4 from "geotiff-geokeys-to-proj4";
@@ -54,6 +54,99 @@ const rangeFetcher = (url) => (a, b) =>
54
54
  .then((r) => r.arrayBuffer())
55
55
  .then((b) => new Uint8Array(b));
56
56
 
57
+ /** Map a level descriptor's sample format + bit depth to a numpy-style dtype. */
58
+ function dtypeOf(lv) {
59
+ const b = lv.bits_per_sample;
60
+ const f = (lv.sample_format || "").toLowerCase();
61
+ if (f.includes("float") || f.includes("ieee")) return "float" + b;
62
+ if (f === "uint" || f.includes("unsigned")) return "uint" + b;
63
+ if (f === "int" || f.includes("signed")) return "int" + b;
64
+ return (f || "uint") + b;
65
+ }
66
+
67
+ /** Min/max/mean/std/count/valid_percent/percentiles/histogram for a band buffer. */
68
+ function computeStats(buf, nodata) {
69
+ let min = Infinity, max = -Infinity, sum = 0, sumsq = 0;
70
+ const valid = [];
71
+ for (let i = 0; i < buf.length; i++) {
72
+ const v = buf[i];
73
+ if (Number.isNaN(v)) continue;
74
+ if (nodata != null && v === nodata) continue;
75
+ if (v < min) min = v;
76
+ if (v > max) max = v;
77
+ sum += v;
78
+ sumsq += v * v;
79
+ valid.push(v);
80
+ }
81
+ const count = valid.length;
82
+ if (count === 0) return { count: 0, valid_percent: 0 };
83
+ const mean = sum / count;
84
+ const std = Math.sqrt(Math.max(0, sumsq / count - mean * mean));
85
+ valid.sort((a, b) => a - b);
86
+ const pct = (p) => valid[Math.min(count - 1, Math.floor((p / 100) * count))];
87
+ const bins = 10, span = max - min || 1, hist = new Array(bins).fill(0);
88
+ for (const v of valid) {
89
+ let k = Math.floor(((v - min) / span) * bins);
90
+ if (k >= bins) k = bins - 1;
91
+ if (k < 0) k = 0;
92
+ hist[k]++;
93
+ }
94
+ const edges = Array.from({ length: bins + 1 }, (_, i) => min + (span * i) / bins);
95
+ return {
96
+ min, max, mean, std, count,
97
+ valid_percent: (count / buf.length) * 100,
98
+ median: pct(50),
99
+ percentile_2: pct(2),
100
+ percentile_98: pct(98),
101
+ histogram: [hist, edges],
102
+ };
103
+ }
104
+
105
+ /** Transfer curve for a rescaled value in 0..1: stretch then gamma (mirrors the
106
+ * Rust `transfer`; reversal is colormap-only so it's not applied to RGB). */
107
+ function transferCurve(t, stretch, gamma) {
108
+ if (stretch === "sqrt") t = Math.sqrt(t);
109
+ else if (stretch === "log") t = Math.log(1 + 99 * t) / Math.log(100);
110
+ if (Math.abs(gamma - 1) > 1e-9) t = Math.pow(t, 1 / Math.max(gamma, 1e-4));
111
+ return t;
112
+ }
113
+
114
+ /** Normalize render rescale options to a list of [min,max] pairs (per band). */
115
+ function rescaleList(opts) {
116
+ if (Array.isArray(opts.rescale) && opts.rescale.length) {
117
+ return Array.isArray(opts.rescale[0]) ? opts.rescale : [opts.rescale];
118
+ }
119
+ return [[opts.min ?? 0, opts.max ?? 1]];
120
+ }
121
+
122
+ /** WGS84 [w,s,e,n] -> EPSG:3857 [minx,miny,maxx,maxy]. */
123
+ function mercExtentFromLonLat([w, s, e, n]) {
124
+ const t = proj4("EPSG:4326", "EPSG:3857");
125
+ const [minx, miny] = t.forward([w, s]);
126
+ const [maxx, maxy] = t.forward([e, n]);
127
+ return [minx, miny, maxx, maxy];
128
+ }
129
+
130
+ /** Output [w,h] honoring explicit width/height, else fitting `maxSize` to aspect.
131
+ * width/height/maxSize are user inputs, so validate them as positive integers
132
+ * before they reach typed-array / canvas constructors. */
133
+ function fitSize(extW, extH, maxSize, width, height) {
134
+ const posInt = (v, name) => {
135
+ if (v == null) return undefined;
136
+ const n = Math.floor(v);
137
+ if (!Number.isFinite(n) || n < 1) throw new Error(`${name} must be a positive integer`);
138
+ return n;
139
+ };
140
+ const w = posInt(width, "width"), h = posInt(height, "height"), max = posInt(maxSize, "maxSize") ?? 1024;
141
+ const ar = extW / extH || 1;
142
+ if (w && h) return [w, h];
143
+ if (w) return [w, Math.max(1, Math.round(w / ar))];
144
+ if (h) return [Math.max(1, Math.round(h * ar)), h];
145
+ return ar >= 1
146
+ ? [max, Math.max(1, Math.round(max / ar))]
147
+ : [Math.max(1, Math.round(max * ar)), max];
148
+ }
149
+
57
150
  /** Build a 256-entry RGBA palette from a TIFF ColorMap (16-bit R,G,B blocks). */
58
151
  function buildPalette(colorMap) {
59
152
  if (!colorMap) return null;
@@ -76,15 +169,60 @@ function bilin(v00, v10, v01, v11, tx, ty) {
76
169
  return top + (bot - top) * ty;
77
170
  }
78
171
 
172
+ // Bilinear sample of a row-major window at fractional (fc,fr). Returns NaN when
173
+ // the cell center is outside the window so out-of-raster pixels stay transparent
174
+ // (no edge smear); falls back to nearest at the window edge / next to nodata.
175
+ function sampleWindowBilinear(buf, w, h, fc, fr) {
176
+ const c0 = Math.floor(fc), r0 = Math.floor(fr);
177
+ if (c0 < 0 || c0 >= w || r0 < 0 || r0 >= h) return NaN;
178
+ const c1 = Math.min(c0 + 1, w - 1), r1 = Math.min(r0 + 1, h - 1);
179
+ const v00 = buf[r0 * w + c0];
180
+ if (Number.isNaN(v00)) return NaN;
181
+ const v10 = buf[r0 * w + c1], v01 = buf[r1 * w + c0], v11 = buf[r1 * w + c1];
182
+ if (Number.isNaN(v10) || Number.isNaN(v01) || Number.isNaN(v11)) return v00; // edge/nodata
183
+ const tx = fc - c0, ty = fr - r0;
184
+ const top = v00 + (v10 - v00) * tx, bot = v01 + (v11 - v01) * tx;
185
+ return top + (bot - top) * ty;
186
+ }
187
+
188
+ // Build a byte reader from a URL string or an in-memory source (ArrayBuffer,
189
+ // Uint8Array, or Blob/File). `range(a,b)` yields bytes [a..b]; `openTiff()`
190
+ // returns a geotiff.js GeoTIFF for reading the CRS / color table from the header.
191
+ async function makeReader(source) {
192
+ if (typeof source === "string") {
193
+ return { label: source, range: rangeFetcher(source), openTiff: () => GeoTIFF.fromUrl(source) };
194
+ }
195
+ let bytes;
196
+ if (source instanceof Uint8Array) bytes = source;
197
+ else if (source instanceof ArrayBuffer) bytes = new Uint8Array(source);
198
+ else if (source && typeof source.arrayBuffer === "function") {
199
+ bytes = new Uint8Array(await source.arrayBuffer()); // Blob / File
200
+ } else {
201
+ throw new Error("openCog: expected a URL string, ArrayBuffer, Uint8Array, or Blob");
202
+ }
203
+ // Reuse the underlying buffer when the view spans it exactly; only copy for a
204
+ // partial view (avoids duplicating a large raster in memory).
205
+ const ab =
206
+ bytes.byteOffset === 0 && bytes.byteLength === bytes.buffer.byteLength
207
+ ? bytes.buffer
208
+ : bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
209
+ return {
210
+ label: source.name || "(local file)",
211
+ range: (a, b) => Promise.resolve(bytes.subarray(a, Math.min(b + 1, bytes.length))),
212
+ openTiff: () => GeoTIFF.fromArrayBuffer(ab),
213
+ };
214
+ }
215
+
79
216
  /**
80
- * Open a COG and return a {@link CogSource} ready to render XYZ tiles.
81
- * Detects EPSG:3857 (fast path) vs. any other CRS (warp path), and reads the
82
- * source projection + color table from the GeoTIFF header (whitebox-wasm 0.4.0
83
- * does not expose them).
217
+ * Open a COG and return a {@link CogSource} ready to render XYZ tiles. `source`
218
+ * is a URL string (read via HTTP range) or in-memory bytes / a Blob / a File for
219
+ * a local raster. Detects EPSG:3857 (fast path) vs. any other CRS (warp path),
220
+ * reading the source projection + color table from the GeoTIFF header (which
221
+ * whitebox-wasm 0.4.0 does not expose).
84
222
  */
85
- export async function openCog(url) {
223
+ export async function openCog(source) {
86
224
  await init();
87
- const range = rangeFetcher(url);
225
+ const { range, openTiff, label } = await makeReader(source);
88
226
  // Parse the COG header; grow the prefix and retry for large COGs whose IFDs
89
227
  // exceed 64 KB (many overviews / huge tile-offset arrays).
90
228
  let stream;
@@ -101,17 +239,7 @@ export async function openCog(url) {
101
239
  if (!Array.isArray(levels) || levels.length === 0) {
102
240
  throw new Error("levels_json() returned no levels");
103
241
  }
104
- // CogTiler renders an assembled f64 window -> RGBA (rescale + colormap +
105
- // nodata). render() ignores the CRS, so we build one in both modes.
106
- const tiler = new CogTiler(
107
- Float64Array.from(gt),
108
- levels[0].width,
109
- levels[0].height,
110
- 3857,
111
- stream.nodata,
112
- JSON.stringify(levels),
113
- );
114
- const base = { url, range, stream, tiler, levels, gt, nodata: stream.nodata };
242
+ const base = { url: label, range, stream, levels, gt, nodata: stream.nodata };
115
243
 
116
244
  if (stream.epsg === 3857) {
117
245
  return new CogSource({
@@ -119,12 +247,13 @@ export async function openCog(url) {
119
247
  mode: "3857",
120
248
  crsLabel: "EPSG:3857",
121
249
  palette: null,
250
+ toSource: { forward: (c) => c }, // identity: mercator meters == source meters
122
251
  boundsLonLat: Array.from(stream.bounds_lonlat()),
123
252
  });
124
253
  }
125
254
 
126
255
  // Warp path: read the real source CRS + optional palette from the header.
127
- const tiff = await GeoTIFF.fromUrl(url);
256
+ const tiff = await openTiff();
128
257
  const img = await tiff.getImage();
129
258
  const srcDef = geokeysToProj4.toProj4(img.getGeoKeys()).proj4;
130
259
  if (!srcDef) throw new Error("could not derive source CRS from GeoTIFF geokeys");
@@ -166,11 +295,9 @@ export class CogSource {
166
295
  return !!this.palette;
167
296
  }
168
297
 
169
- /** Render an XYZ tile to a 256x256 RGBA `Uint8ClampedArray`, or null if empty. */
298
+ /** Render an XYZ tile to a 256x256 RGBA buffer, or null if empty. */
170
299
  async renderTileRGBA(z, x, y, opts = {}) {
171
- return this.mode === "warp"
172
- ? this._warp(z, x, y, opts)
173
- : this._render3857(z, x, y, opts);
300
+ return this._renderExtent(tileBounds3857(z, x, y), TILE, TILE, opts);
174
301
  }
175
302
 
176
303
  /** Render an XYZ tile to PNG bytes (empty `Uint8Array` for a blank tile). */
@@ -200,8 +327,9 @@ export class CogSource {
200
327
  }
201
328
 
202
329
  // Fetch (parallel, cached) + decode the source tiles covering a level pixel
203
- // window and assemble them into a row-major f64 buffer (NaN = no data).
204
- async _assembleWindow(level, x, y, w, h) {
330
+ // window and assemble band `band` (0-based) into a row-major f64 buffer
331
+ // (NaN = no data). decode_tile_f64 is pixel-interleaved, so stride by `bands`.
332
+ async _assembleWindow(level, x, y, w, h, band = 0) {
205
333
  const lv = this.levels[level];
206
334
  const tiles = JSON.parse(this.stream.tiles_for_window(level, x, y, w, h));
207
335
  const decoded = await Promise.all(tiles.map((t) => this._getTile(level, t)));
@@ -216,7 +344,7 @@ export class CogSource {
216
344
  for (let rx = 0; rx < tw; rx++) {
217
345
  const ox = tx0 + rx - x;
218
346
  if (ox < 0 || ox >= w) continue;
219
- buf[oy * w + ox] = px[(ry * tw + rx) * bands]; // band 0
347
+ buf[oy * w + ox] = px[(ry * tw + rx) * bands + band];
220
348
  }
221
349
  }
222
350
  });
@@ -244,28 +372,40 @@ export class CogSource {
244
372
  ];
245
373
  }
246
374
 
247
- // Fast path: source already EPSG:3857, so a tile maps affinely to a window.
248
- async _render3857(z, x, y, { min = 0, max = 1, colormap = "viridis" } = {}) {
249
- const win = this.tiler.pixel_window_for_tile(z, x, y);
250
- if (win.empty) return null;
251
- const buf = await this._assembleWindow(win.level, win.x, win.y, win.w, win.h);
252
- return this.tiler.render(buf, win.w, win.h, min, max, colormap, true);
253
- }
375
+ // Render a Web Mercator extent (3857 [minx,miny,maxx,maxy]) to an outW x outH
376
+ // RGBA buffer. A coarse grid of mercator->source samples (the proj4 transform,
377
+ // or identity for EPSG:3857) is bilinearly interpolated per output pixel to a
378
+ // source location, then sampled from the source window. Bands: 1 (paletted via
379
+ // the color table, else single-band colormap) or >=3 (RGB composite, per-band
380
+ // rescale). Out-of-raster pixels stay transparent. Powers tiles, preview, bbox.
381
+ async _renderExtent(merc, outW, outH, opts = {}) {
382
+ const [minx, miny, maxx, maxy] = merc;
383
+ const l0 = this.levels[0];
384
+ const wanted = opts.bidx && opts.bidx.length ? opts.bidx : this.palette ? [1] : l0.bands >= 3 ? [1, 2, 3] : [1];
385
+ const bands0 = wanted.map((b) => b - 1).filter((b) => b >= 0 && b < l0.bands);
386
+ if (!bands0.length) bands0.push(0);
387
+ const rgb = bands0.length >= 3;
388
+ const rescales = rescaleList(opts);
389
+ const colormap = opts.colormap || "viridis";
390
+ const nodata = opts.nodata != null ? opts.nodata : this.nodata;
391
+ const ndSet = nodata != null && !Number.isNaN(nodata);
392
+ // Transfer-curve params (parity with maplibre-gl-raster's shader pipeline).
393
+ // Normalize gamma/opacity here so the JS (RGB) and Rust (single-band) paths
394
+ // apply identical curves for the same options (e.g. gamma 0 -> clamped, not skipped).
395
+ const stretch = opts.stretch || "linear";
396
+ const gamma = Number.isFinite(+opts.gamma) ? Math.max(+opts.gamma, 1e-4) : 1;
397
+ const reversed = !!opts.reversed;
398
+ const opacity = Number.isFinite(+opts.opacity) ? Math.max(0, Math.min(1, +opts.opacity)) : 1;
399
+ const alpha = Math.round(opacity * 255);
254
400
 
255
- // Warp path: reproject a Web Mercator tile from the source CRS on the fly.
256
- // A coarse grid of mercator->source samples (proj4) is bilinearly interpolated
257
- // per output pixel, then nearest-sampled from the source window (correct for
258
- // categorical data). Paletted sources use the color table; others reuse the
259
- // Rust colormap (render resamples 256->256, ~identity).
260
- async _warp(z, x, y, { min = 0, max = 1, colormap = "viridis" } = {}) {
261
- const tb = tileBounds3857(z, x, y);
401
+ // Gridded mercator -> source samples, and the source-coord bbox they span.
262
402
  const nx = new Float64Array((NG + 1) * (NG + 1));
263
403
  const ny = new Float64Array((NG + 1) * (NG + 1));
264
404
  let sminx = Infinity, sminy = Infinity, smaxx = -Infinity, smaxy = -Infinity, any = false;
265
405
  for (let gy = 0; gy <= NG; gy++) {
266
- const my = tb[3] - (gy / NG) * (tb[3] - tb[1]);
406
+ const my = maxy - (gy / NG) * (maxy - miny);
267
407
  for (let gx = 0; gx <= NG; gx++) {
268
- const mx = tb[0] + (gx / NG) * (tb[2] - tb[0]);
408
+ const mx = minx + (gx / NG) * (maxx - minx);
269
409
  let s;
270
410
  try { s = this.toSource.forward([mx, my]); } catch { s = [NaN, NaN]; }
271
411
  const i = gy * (NG + 1) + gx;
@@ -281,7 +421,7 @@ export class CogSource {
281
421
  }
282
422
  if (!any) return null;
283
423
 
284
- const level = this._chooseLevel((smaxx - sminx) / TILE);
424
+ const level = this._chooseLevel((smaxx - sminx) / outW);
285
425
  const lv = this.levels[level];
286
426
  const [lpw, lph] = this._levelPixelSize(level);
287
427
  const ox = this.gt[0], oy = this.gt[3];
@@ -291,49 +431,221 @@ export class CogSource {
291
431
  r0 = Math.max(0, Math.min(r0, lv.height)); r1 = Math.max(0, Math.min(r1, lv.height));
292
432
  const ww = c1 - c0, hh = r1 - r0;
293
433
  if (ww <= 0 || hh <= 0) return null;
294
- const buf = await this._assembleWindow(level, c0, r0, ww, hh);
295
434
 
435
+ // Assemble the needed band window(s): 1 (palette/colormap) or 3 (RGB).
436
+ const used = rgb ? bands0.slice(0, 3) : [bands0[0]];
437
+ const bufs = await Promise.all(used.map((b) => this._assembleWindow(level, c0, r0, ww, hh, b)));
296
438
  const pal = this.palette;
297
- const out = new Uint8ClampedArray(TILE * TILE * 4);
298
- const grid = pal ? null : new Float64Array(TILE * TILE).fill(NaN);
299
- for (let py = 0; py < TILE; py++) {
300
- const fy = (py / TILE) * NG, gy0 = Math.min(NG - 1, Math.floor(fy)), ty = fy - gy0;
301
- for (let px = 0; px < TILE; px++) {
302
- const fx = (px / TILE) * NG, gx0 = Math.min(NG - 1, Math.floor(fx)), tx = fx - gx0;
439
+
440
+ const out = new Uint8ClampedArray(outW * outH * 4);
441
+ const grid = !pal && !rgb ? new Float64Array(outW * outH).fill(NaN) : null;
442
+ for (let py = 0; py < outH; py++) {
443
+ const fy = (py / outH) * NG, gy0 = Math.min(NG - 1, Math.floor(fy)), ty = fy - gy0;
444
+ for (let px = 0; px < outW; px++) {
445
+ const fx = (px / outW) * NG, gx0 = Math.min(NG - 1, Math.floor(fx)), tx = fx - gx0;
303
446
  const i00 = gy0 * (NG + 1) + gx0;
304
447
  const sx = bilin(nx[i00], nx[i00 + 1], nx[i00 + NG + 1], nx[i00 + NG + 2], tx, ty);
305
448
  const sy = bilin(ny[i00], ny[i00 + 1], ny[i00 + NG + 1], ny[i00 + NG + 2], tx, ty);
306
449
  if (!isFinite(sx) || !isFinite(sy)) continue;
307
- const col = Math.floor((sx - ox) / lpw) - c0;
308
- const row = Math.floor((oy - sy) / lph) - r0;
309
- if (col < 0 || col >= ww || row < 0 || row >= hh) continue;
310
- const v = buf[row * ww + col];
311
- if (!isFinite(v)) continue;
450
+ const fcol = (sx - ox) / lpw - c0;
451
+ const frow = (oy - sy) / lph - r0;
452
+ const o = (py * outW + px) * 4;
312
453
  if (pal) {
454
+ // Categorical: nearest-neighbor + color table.
455
+ const col = Math.floor(fcol), row = Math.floor(frow);
456
+ if (col < 0 || col >= ww || row < 0 || row >= hh) continue;
457
+ const v = bufs[0][row * ww + col];
458
+ if (!isFinite(v)) continue;
313
459
  const ci = v & 255;
314
- if (ci === 0 || (this.nodata != null && v === this.nodata)) continue;
315
- const o = (py * TILE + px) * 4;
460
+ // Transparency: the declared nodata when present, else the GDAL
461
+ // paletted convention that index 0 is the background/no-data class.
462
+ if (ndSet ? v === nodata : ci === 0) continue;
316
463
  out[o] = pal[ci * 4]; out[o + 1] = pal[ci * 4 + 1];
317
- out[o + 2] = pal[ci * 4 + 2]; out[o + 3] = 255;
464
+ out[o + 2] = pal[ci * 4 + 2]; out[o + 3] = alpha;
465
+ } else if (rgb) {
466
+ // RGB composite: bilinear-sample each band, rescale -> curve -> gamma.
467
+ let ok = true;
468
+ for (let k = 0; k < 3; k++) {
469
+ const v = sampleWindowBilinear(bufs[k], ww, hh, fcol, frow);
470
+ if (!isFinite(v) || (ndSet && v === nodata)) { ok = false; break; }
471
+ const [mn, mx] = rescales[k] || rescales[0];
472
+ const t = transferCurve(Math.max(0, Math.min(1, (v - mn) / ((mx - mn) || 1))), stretch, gamma);
473
+ out[o + k] = Math.round(t * 255);
474
+ }
475
+ if (ok) out[o + 3] = alpha;
476
+ else { out[o] = out[o + 1] = out[o + 2] = 0; }
318
477
  } else {
319
- grid[py * TILE + px] = v;
478
+ // Continuous single band: bilinear; colormap applied below.
479
+ const v = sampleWindowBilinear(bufs[0], ww, hh, fcol, frow);
480
+ if (!isFinite(v) || (ndSet && v === nodata)) continue;
481
+ grid[py * outW + px] = v;
320
482
  }
321
483
  }
322
484
  }
323
- if (pal) return out;
324
- return this.tiler.render(grid, TILE, TILE, min, max, colormap, true);
485
+ if (pal || rgb) return out;
486
+ const [mn, mx] = rescales[0];
487
+ // colorize returns a Uint8Array; expose a Uint8ClampedArray (zero-copy view,
488
+ // matching the palette/RGB branches and the RenderedImage type) so callers
489
+ // can pass it straight to ImageData.
490
+ const c = colorize(
491
+ grid, outW, outH, mn, mx, colormap, ndSet ? nodata : undefined, true,
492
+ stretch, gamma, reversed, opacity,
493
+ );
494
+ return new Uint8ClampedArray(c.buffer, c.byteOffset, c.length);
495
+ }
496
+
497
+ // --- TiTiler-style read API ----------------------------------------------
498
+
499
+ /** XYZ zoom whose tile resolution matches the full-res pixel size. */
500
+ _maxzoom() {
501
+ const res = Math.abs(this.gt[1]);
502
+ return Math.max(0, Math.min(24, Math.round(Math.log2((2 * OS) / (TILE * res)))));
503
+ }
504
+
505
+ /** XYZ zoom whose tile resolution matches the coarsest overview. */
506
+ _minzoom() {
507
+ const c = this.levels[this.levels.length - 1];
508
+ const res = Math.abs(this.gt[1]) * (this.levels[0].width / c.width);
509
+ return Math.max(0, Math.min(24, Math.round(Math.log2((2 * OS) / (TILE * res)))));
510
+ }
511
+
512
+ /** Dataset info (≈ TiTiler `/cog/info`). */
513
+ info() {
514
+ const l0 = this.levels[0];
515
+ const b = this.boundsLonLat;
516
+ return {
517
+ bounds: b, // WGS84 [minlon, minlat, maxlon, maxlat]
518
+ crs: this.crsLabel,
519
+ width: l0.width,
520
+ height: l0.height,
521
+ count: l0.bands,
522
+ dtype: dtypeOf(l0),
523
+ nodata: this.nodata == null || Number.isNaN(this.nodata) ? null : this.nodata,
524
+ colorinterp: this.palette ? ["palette"] : null,
525
+ overviews: this.levels.length - 1,
526
+ tile_size: [l0.tile_width, l0.tile_height],
527
+ minzoom: this._minzoom(),
528
+ maxzoom: this._maxzoom(),
529
+ band_descriptions: Array.from({ length: l0.bands }, (_, i) => `b${i + 1}`),
530
+ compression: l0.compression,
531
+ };
532
+ }
533
+
534
+ /** Dataset info as a GeoJSON Feature (≈ TiTiler `/cog/info.geojson`). */
535
+ infoGeoJSON() {
536
+ const [w, s, e, n] = this.boundsLonLat;
537
+ return {
538
+ type: "Feature",
539
+ geometry: {
540
+ type: "Polygon",
541
+ coordinates: [[[w, s], [e, s], [e, n], [w, n], [w, s]]],
542
+ },
543
+ properties: this.info(),
544
+ };
545
+ }
546
+
547
+ /** Mapbox TileJSON document (≈ TiTiler `/cog/tilejson.json`). */
548
+ tilejson({ tilesUrl = "cog://{z}/{x}/{y}", minzoom, maxzoom, scheme = "xyz" } = {}) {
549
+ const b = this.boundsLonLat;
550
+ const mn = minzoom ?? this._minzoom();
551
+ return {
552
+ tilejson: "2.2.0",
553
+ version: "1.0.0",
554
+ scheme,
555
+ tiles: [tilesUrl],
556
+ minzoom: mn,
557
+ maxzoom: maxzoom ?? this._maxzoom(),
558
+ bounds: b,
559
+ center: [(b[0] + b[2]) / 2, (b[1] + b[3]) / 2, mn],
560
+ };
561
+ }
562
+
563
+ /** Band value(s) at a WGS84 lon/lat (≈ TiTiler `/cog/point/{lon},{lat}`). */
564
+ async point(lon, lat, { bidx } = {}) {
565
+ const [mx, my] = proj4("EPSG:4326", "EPSG:3857").forward([lon, lat]);
566
+ const [sx, sy] = this.toSource.forward([mx, my]); // source CRS coords
567
+ const l0 = this.levels[0];
568
+ const col = Math.floor((sx - this.gt[0]) / Math.abs(this.gt[1]));
569
+ const row = Math.floor((this.gt[3] - sy) / Math.abs(this.gt[5]));
570
+ if (col < 0 || col >= l0.width || row < 0 || row >= l0.height) {
571
+ return { coordinates: [lon, lat], values: [], band_names: [], outside: true };
572
+ }
573
+ const tcol = Math.floor(col / l0.tile_width), trow = Math.floor(row / l0.tile_height);
574
+ const [off, len] = Array.from(this.stream.tile_range(0, tcol, trow));
575
+ const px = await this._getTile(0, { col: tcol, row: trow, offset: off, length: len });
576
+ const base = ((row % l0.tile_height) * l0.tile_width + (col % l0.tile_width)) * l0.bands;
577
+ const bands = bidx ? bidx.map((b) => b - 1) : Array.from({ length: l0.bands }, (_, i) => i);
578
+ return {
579
+ coordinates: [lon, lat],
580
+ values: bands.map((b) => px[base + b]),
581
+ band_names: bands.map((b) => `b${b + 1}`),
582
+ };
583
+ }
584
+
585
+ /** Per-band statistics (≈ TiTiler `/cog/statistics`), from a decimated
586
+ * overview (the largest one whose width is ≤ `maxSize`). */
587
+ async statistics({ maxSize = 1024 } = {}) {
588
+ let level = this.levels.length - 1;
589
+ for (let i = 0; i < this.levels.length; i++) {
590
+ if (this.levels[i].width <= maxSize) { level = i; break; }
591
+ }
592
+ const lv = this.levels[level];
593
+ const nodata = this.nodata == null || Number.isNaN(this.nodata) ? null : this.nodata;
594
+ const out = {};
595
+ for (let b = 0; b < lv.bands; b++) {
596
+ const buf = await this._assembleWindow(level, 0, 0, lv.width, lv.height, b);
597
+ out[`b${b + 1}`] = computeStats(buf, nodata);
598
+ }
599
+ return out;
600
+ }
601
+
602
+ /** Render a preview of the whole dataset (≈ TiTiler `/cog/preview`).
603
+ * Returns `{ width, height, rgba }`. `opts` accepts the render params
604
+ * (`bidx`, `min`/`max`/`rescale`, `colormap`, `nodata`) plus `maxSize` /
605
+ * `width` / `height`. */
606
+ async preview({ maxSize = 1024, width, height, ...render } = {}) {
607
+ const merc = mercExtentFromLonLat(this.boundsLonLat);
608
+ const [w, h] = fitSize(merc[2] - merc[0], merc[3] - merc[1], maxSize, width, height);
609
+ const rgba = (await this._renderExtent(merc, w, h, render)) || new Uint8ClampedArray(w * h * 4);
610
+ return { width: w, height: h, rgba };
611
+ }
612
+
613
+ /** Render a WGS84 bbox region (≈ TiTiler `/cog/bbox`). `bbox` is
614
+ * [minLon, minLat, maxLon, maxLat]. Returns `{ width, height, rgba }`. */
615
+ async bbox(bbox, { maxSize = 1024, width, height, ...render } = {}) {
616
+ const merc = mercExtentFromLonLat(bbox);
617
+ const [w, h] = fitSize(merc[2] - merc[0], merc[3] - merc[1], maxSize, width, height);
618
+ const rgba = (await this._renderExtent(merc, w, h, render)) || new Uint8ClampedArray(w * h * 4);
619
+ return { width: w, height: h, rgba };
620
+ }
621
+
622
+ /** Like {@link preview}, encoded as PNG bytes. */
623
+ async previewPNG(opts = {}) {
624
+ const { width, height, rgba } = await this.preview(opts);
625
+ return rgbaToPng(rgba, width, height);
626
+ }
627
+
628
+ /** Like {@link bbox}, encoded as PNG bytes. */
629
+ async bboxPNG(bbox, opts = {}) {
630
+ const r = await this.bbox(bbox, opts);
631
+ return rgbaToPng(r.rgba, r.width, r.height);
325
632
  }
326
633
  }
327
634
 
328
- /** Encode a 256x256 RGBA buffer to PNG bytes (browser; uses OffscreenCanvas). */
329
- export async function rgbaToPng(rgba) {
330
- const img = new ImageData(new Uint8ClampedArray(rgba), TILE, TILE);
331
- const cv = new OffscreenCanvas(TILE, TILE);
635
+ /** Encode a `w`x`h` RGBA buffer to PNG bytes (browser; uses OffscreenCanvas). */
636
+ export async function rgbaToPng(rgba, w = TILE, h = TILE) {
637
+ const img = new ImageData(new Uint8ClampedArray(rgba), w, h);
638
+ const cv = new OffscreenCanvas(w, h);
332
639
  cv.getContext("2d").putImageData(img, 0, 0);
333
640
  const blob = await cv.convertToBlob({ type: "image/png" });
334
641
  return new Uint8Array(await blob.arrayBuffer());
335
642
  }
336
643
 
644
+ /** Names of the built-in colormaps (for single-band rendering). */
645
+ export function colormaps() {
646
+ return JSON.parse(colormap_names());
647
+ }
648
+
337
649
  /**
338
650
  * Register a MapLibre custom protocol (e.g. `cog://{z}/{x}/{y}`).
339
651
  * `resolve()` is called per tile and returns `{ source, render }`, where
@@ -50,6 +50,20 @@ export class CogTiler {
50
50
  readonly num_levels: number;
51
51
  }
52
52
 
53
+ /**
54
+ * Colormap a single-band `w*h` grid to RGBA at 1:1 (no resampling), for any
55
+ * output size, with rescale -> stretch -> gamma -> colormap (+ reverse) and a
56
+ * constant alpha from `opacity`. `NaN` (and the nodata value when
57
+ * `nodata_alpha`) become transparent. Pairs with the JS warp loop, which
58
+ * samples a grid at the output resolution.
59
+ */
60
+ export function colorize(pixels: Float64Array, width: number, height: number, min: number, max: number, colormap: string, nodata: number | null | undefined, nodata_alpha: boolean, stretch: string, gamma: number, reversed: boolean, opacity: number): Uint8Array;
61
+
62
+ /**
63
+ * Names of the built-in colormaps (JSON array string).
64
+ */
65
+ export function colormap_names(): string;
66
+
53
67
  /**
54
68
  * EPSG:3857 bounds `[min_x, min_y, max_x, max_y]` of an XYZ tile.
55
69
  */
@@ -70,6 +84,8 @@ export interface InitOutput {
70
84
  readonly cogtiler_num_levels: (a: number) => number;
71
85
  readonly cogtiler_pixel_window_for_tile: (a: number, b: number, c: number, d: number, e: number) => void;
72
86
  readonly cogtiler_render: (a: number, b: number, c: number, d: number, e: number, f: number, g: number, h: number, i: number, j: number, k: number) => void;
87
+ readonly colorize: (a: number, b: number, c: number, d: number, e: number, f: number, g: number, h: number, i: number, j: number, k: number, l: number, m: number, n: number, o: number, p: number, q: number) => void;
88
+ readonly colormap_names: (a: number) => void;
73
89
  readonly tile_bounds_3857: (a: number, b: number, c: number, d: number) => void;
74
90
  readonly version: (a: number) => void;
75
91
  readonly __wbindgen_export: (a: number, b: number) => number;
package/cog_tiler_wasm.js CHANGED
@@ -134,6 +134,67 @@ export class CogTiler {
134
134
  }
135
135
  if (Symbol.dispose) CogTiler.prototype[Symbol.dispose] = CogTiler.prototype.free;
136
136
 
137
+ /**
138
+ * Colormap a single-band `w*h` grid to RGBA at 1:1 (no resampling), for any
139
+ * output size, with rescale -> stretch -> gamma -> colormap (+ reverse) and a
140
+ * constant alpha from `opacity`. `NaN` (and the nodata value when
141
+ * `nodata_alpha`) become transparent. Pairs with the JS warp loop, which
142
+ * samples a grid at the output resolution.
143
+ * @param {Float64Array} pixels
144
+ * @param {number} width
145
+ * @param {number} height
146
+ * @param {number} min
147
+ * @param {number} max
148
+ * @param {string} colormap
149
+ * @param {number | null | undefined} nodata
150
+ * @param {boolean} nodata_alpha
151
+ * @param {string} stretch
152
+ * @param {number} gamma
153
+ * @param {boolean} reversed
154
+ * @param {number} opacity
155
+ * @returns {Uint8Array}
156
+ */
157
+ export function colorize(pixels, width, height, min, max, colormap, nodata, nodata_alpha, stretch, gamma, reversed, opacity) {
158
+ try {
159
+ const retptr = wasm.__wbindgen_add_to_stack_pointer(-16);
160
+ const ptr0 = passArrayF64ToWasm0(pixels, wasm.__wbindgen_export);
161
+ const len0 = WASM_VECTOR_LEN;
162
+ const ptr1 = passStringToWasm0(colormap, wasm.__wbindgen_export, wasm.__wbindgen_export2);
163
+ const len1 = WASM_VECTOR_LEN;
164
+ const ptr2 = passStringToWasm0(stretch, wasm.__wbindgen_export, wasm.__wbindgen_export2);
165
+ const len2 = WASM_VECTOR_LEN;
166
+ wasm.colorize(retptr, ptr0, len0, width, height, min, max, ptr1, len1, !isLikeNone(nodata), isLikeNone(nodata) ? 0 : nodata, nodata_alpha, ptr2, len2, gamma, reversed, opacity);
167
+ var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true);
168
+ var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true);
169
+ var v4 = getArrayU8FromWasm0(r0, r1).slice();
170
+ wasm.__wbindgen_export3(r0, r1 * 1, 1);
171
+ return v4;
172
+ } finally {
173
+ wasm.__wbindgen_add_to_stack_pointer(16);
174
+ }
175
+ }
176
+
177
+ /**
178
+ * Names of the built-in colormaps (JSON array string).
179
+ * @returns {string}
180
+ */
181
+ export function colormap_names() {
182
+ let deferred1_0;
183
+ let deferred1_1;
184
+ try {
185
+ const retptr = wasm.__wbindgen_add_to_stack_pointer(-16);
186
+ wasm.colormap_names(retptr);
187
+ var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true);
188
+ var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true);
189
+ deferred1_0 = r0;
190
+ deferred1_1 = r1;
191
+ return getStringFromWasm0(r0, r1);
192
+ } finally {
193
+ wasm.__wbindgen_add_to_stack_pointer(16);
194
+ wasm.__wbindgen_export3(deferred1_0, deferred1_1, 1);
195
+ }
196
+ }
197
+
137
198
  /**
138
199
  * EPSG:3857 bounds `[min_x, min_y, max_x, max_y]` of an XYZ tile.
139
200
  * @param {number} z
Binary file
package/package.json CHANGED
@@ -5,8 +5,8 @@
5
5
  "Qiusheng Wu <giswqs@gmail.com>"
6
6
  ],
7
7
  "description": "Serverless Cloud Optimized GeoTIFF (COG) dynamic tiling in WebAssembly. TiTiler-style XYZ tiles, no backend.",
8
- "version": "0.1.0",
9
- "license": "MIT OR Apache-2.0",
8
+ "version": "0.2.0",
9
+ "license": "MIT",
10
10
  "repository": {
11
11
  "type": "git",
12
12
  "url": "https://github.com/opengeos/cog-tiler-wasm"
@@ -16,7 +16,9 @@
16
16
  "cog_tiler_wasm.js",
17
17
  "cog_tiler_wasm.d.ts",
18
18
  "cog-tiler.js",
19
- "cog-tiler.d.ts"
19
+ "cog-tiler.d.ts",
20
+ "README.md",
21
+ "LICENSE"
20
22
  ],
21
23
  "main": "cog-tiler.js",
22
24
  "types": "cog-tiler.d.ts",