cog-tiler-wasm 0.1.0 → 0.2.1

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,239 @@
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). **Planar**
214
+ (`INTERLEAVE=BAND`) multi-band COGs are read per-band via geotiff.js too, since
215
+ whitebox-wasm's streaming decoder is chunky-only. Next: planar support
216
+ **upstream in `whitebox-wasm`** (and exposing its proj string + color table) to
217
+ drop the geotiff.js dependency, then move the warp into the Rust crate
218
+ (`proj4rs`).
219
+ - **Edge / WASI serving** - run the same module as a serverless XYZ endpoint
220
+ near the data, not only in the browser.
221
+ - **STAC / mosaics** - multi-asset orchestration.
222
+
223
+ ## Releasing
224
+
225
+ The npm package bundles the wasm tiler **and** the `cog-tiler.js` module
226
+ (assembled by [`scripts/prepare-pkg.mjs`](scripts/prepare-pkg.mjs)). To cut a
227
+ release, push a `vX.Y.Z` tag; [`release.yml`](.github/workflows/release.yml)
228
+ builds, assembles, and publishes to npm via Trusted Publishing (OIDC, no token):
229
+
230
+ ```bash
231
+ git tag v0.2.0 && git push origin v0.2.0
232
+ ```
233
+
234
+ One-time setup: configure the package's Trusted Publisher on npmjs.com (package
235
+ -> Settings -> Trusted Publisher) to this repo + `release.yml`.
236
+
237
+ ## License
238
+
239
+ [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,18 @@ 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
+ // Open the GeoTIFF with geotiff.js when we need the CRS (non-3857) or to check
243
+ // the planar config (multi-band). whitebox-wasm's streaming decoder is
244
+ // chunky-only, so planar (INTERLEAVE=BAND) multi-band COGs are read per-band
245
+ // through geotiff.js instead (see _assembleWindow / point).
246
+ const multiBand = levels[0].bands > 1;
247
+ let tiff = null, img = null, planar = false;
248
+ if (stream.epsg !== 3857 || multiBand) {
249
+ tiff = await openTiff();
250
+ img = await tiff.getImage();
251
+ planar = multiBand && img.fileDirectory.PlanarConfiguration === 2;
252
+ }
253
+ const base = { url: label, range, stream, levels, gt, nodata: stream.nodata, tiff, planar };
115
254
 
116
255
  if (stream.epsg === 3857) {
117
256
  return new CogSource({
@@ -119,13 +258,12 @@ export async function openCog(url) {
119
258
  mode: "3857",
120
259
  crsLabel: "EPSG:3857",
121
260
  palette: null,
261
+ toSource: { forward: (c) => c }, // identity: mercator meters == source meters
122
262
  boundsLonLat: Array.from(stream.bounds_lonlat()),
123
263
  });
124
264
  }
125
265
 
126
266
  // Warp path: read the real source CRS + optional palette from the header.
127
- const tiff = await GeoTIFF.fromUrl(url);
128
- const img = await tiff.getImage();
129
267
  const srcDef = geokeysToProj4.toProj4(img.getGeoKeys()).proj4;
130
268
  if (!srcDef) throw new Error("could not derive source CRS from GeoTIFF geokeys");
131
269
  const toSource = proj4("EPSG:3857", srcDef); // forward: mercator -> source
@@ -166,11 +304,9 @@ export class CogSource {
166
304
  return !!this.palette;
167
305
  }
168
306
 
169
- /** Render an XYZ tile to a 256x256 RGBA `Uint8ClampedArray`, or null if empty. */
307
+ /** Render an XYZ tile to a 256x256 RGBA buffer, or null if empty. */
170
308
  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);
309
+ return this._renderExtent(tileBounds3857(z, x, y), TILE, TILE, opts);
174
310
  }
175
311
 
176
312
  /** Render an XYZ tile to PNG bytes (empty `Uint8Array` for a blank tile). */
@@ -199,9 +335,27 @@ export class CogSource {
199
335
  return p;
200
336
  }
201
337
 
202
- // 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) {
338
+ /** geotiff.js image for an overview level (cached). Used for planar reads. */
339
+ _tiffImage(level) {
340
+ if (!this._imgs) this._imgs = new Map();
341
+ let p = this._imgs.get(level);
342
+ if (!p) {
343
+ p = this.tiff.getImage(level);
344
+ this._imgs.set(level, p);
345
+ }
346
+ return p;
347
+ }
348
+
349
+ // Fetch + decode band `band` (0-based) over a level pixel window into a
350
+ // row-major buffer. Chunky COGs go through whitebox (cached, NaN for gaps);
351
+ // planar (INTERLEAVE=BAND) COGs are read per-band via geotiff.js, which
352
+ // whitebox's chunky-only streaming decoder can't address.
353
+ async _assembleWindow(level, x, y, w, h, band = 0) {
354
+ if (this.planar) {
355
+ const img = await this._tiffImage(level);
356
+ const rasters = await img.readRasters({ window: [x, y, x + w, y + h], samples: [band] });
357
+ return rasters[0]; // typed array, length w*h, row-major
358
+ }
205
359
  const lv = this.levels[level];
206
360
  const tiles = JSON.parse(this.stream.tiles_for_window(level, x, y, w, h));
207
361
  const decoded = await Promise.all(tiles.map((t) => this._getTile(level, t)));
@@ -216,7 +370,7 @@ export class CogSource {
216
370
  for (let rx = 0; rx < tw; rx++) {
217
371
  const ox = tx0 + rx - x;
218
372
  if (ox < 0 || ox >= w) continue;
219
- buf[oy * w + ox] = px[(ry * tw + rx) * bands]; // band 0
373
+ buf[oy * w + ox] = px[(ry * tw + rx) * bands + band];
220
374
  }
221
375
  }
222
376
  });
@@ -244,28 +398,40 @@ export class CogSource {
244
398
  ];
245
399
  }
246
400
 
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
- }
401
+ // Render a Web Mercator extent (3857 [minx,miny,maxx,maxy]) to an outW x outH
402
+ // RGBA buffer. A coarse grid of mercator->source samples (the proj4 transform,
403
+ // or identity for EPSG:3857) is bilinearly interpolated per output pixel to a
404
+ // source location, then sampled from the source window. Bands: 1 (paletted via
405
+ // the color table, else single-band colormap) or >=3 (RGB composite, per-band
406
+ // rescale). Out-of-raster pixels stay transparent. Powers tiles, preview, bbox.
407
+ async _renderExtent(merc, outW, outH, opts = {}) {
408
+ const [minx, miny, maxx, maxy] = merc;
409
+ const l0 = this.levels[0];
410
+ const wanted = opts.bidx && opts.bidx.length ? opts.bidx : this.palette ? [1] : l0.bands >= 3 ? [1, 2, 3] : [1];
411
+ const bands0 = wanted.map((b) => b - 1).filter((b) => b >= 0 && b < l0.bands);
412
+ if (!bands0.length) bands0.push(0);
413
+ const rgb = bands0.length >= 3;
414
+ const rescales = rescaleList(opts);
415
+ const colormap = opts.colormap || "viridis";
416
+ const nodata = opts.nodata != null ? opts.nodata : this.nodata;
417
+ const ndSet = nodata != null && !Number.isNaN(nodata);
418
+ // Transfer-curve params (parity with maplibre-gl-raster's shader pipeline).
419
+ // Normalize gamma/opacity here so the JS (RGB) and Rust (single-band) paths
420
+ // apply identical curves for the same options (e.g. gamma 0 -> clamped, not skipped).
421
+ const stretch = opts.stretch || "linear";
422
+ const gamma = Number.isFinite(+opts.gamma) ? Math.max(+opts.gamma, 1e-4) : 1;
423
+ const reversed = !!opts.reversed;
424
+ const opacity = Number.isFinite(+opts.opacity) ? Math.max(0, Math.min(1, +opts.opacity)) : 1;
425
+ const alpha = Math.round(opacity * 255);
254
426
 
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);
427
+ // Gridded mercator -> source samples, and the source-coord bbox they span.
262
428
  const nx = new Float64Array((NG + 1) * (NG + 1));
263
429
  const ny = new Float64Array((NG + 1) * (NG + 1));
264
430
  let sminx = Infinity, sminy = Infinity, smaxx = -Infinity, smaxy = -Infinity, any = false;
265
431
  for (let gy = 0; gy <= NG; gy++) {
266
- const my = tb[3] - (gy / NG) * (tb[3] - tb[1]);
432
+ const my = maxy - (gy / NG) * (maxy - miny);
267
433
  for (let gx = 0; gx <= NG; gx++) {
268
- const mx = tb[0] + (gx / NG) * (tb[2] - tb[0]);
434
+ const mx = minx + (gx / NG) * (maxx - minx);
269
435
  let s;
270
436
  try { s = this.toSource.forward([mx, my]); } catch { s = [NaN, NaN]; }
271
437
  const i = gy * (NG + 1) + gx;
@@ -281,7 +447,7 @@ export class CogSource {
281
447
  }
282
448
  if (!any) return null;
283
449
 
284
- const level = this._chooseLevel((smaxx - sminx) / TILE);
450
+ const level = this._chooseLevel((smaxx - sminx) / outW);
285
451
  const lv = this.levels[level];
286
452
  const [lpw, lph] = this._levelPixelSize(level);
287
453
  const ox = this.gt[0], oy = this.gt[3];
@@ -291,49 +457,230 @@ export class CogSource {
291
457
  r0 = Math.max(0, Math.min(r0, lv.height)); r1 = Math.max(0, Math.min(r1, lv.height));
292
458
  const ww = c1 - c0, hh = r1 - r0;
293
459
  if (ww <= 0 || hh <= 0) return null;
294
- const buf = await this._assembleWindow(level, c0, r0, ww, hh);
295
460
 
461
+ // Assemble the needed band window(s): 1 (palette/colormap) or 3 (RGB).
462
+ const used = rgb ? bands0.slice(0, 3) : [bands0[0]];
463
+ const bufs = await Promise.all(used.map((b) => this._assembleWindow(level, c0, r0, ww, hh, b)));
296
464
  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;
465
+
466
+ const out = new Uint8ClampedArray(outW * outH * 4);
467
+ const grid = !pal && !rgb ? new Float64Array(outW * outH).fill(NaN) : null;
468
+ for (let py = 0; py < outH; py++) {
469
+ const fy = (py / outH) * NG, gy0 = Math.min(NG - 1, Math.floor(fy)), ty = fy - gy0;
470
+ for (let px = 0; px < outW; px++) {
471
+ const fx = (px / outW) * NG, gx0 = Math.min(NG - 1, Math.floor(fx)), tx = fx - gx0;
303
472
  const i00 = gy0 * (NG + 1) + gx0;
304
473
  const sx = bilin(nx[i00], nx[i00 + 1], nx[i00 + NG + 1], nx[i00 + NG + 2], tx, ty);
305
474
  const sy = bilin(ny[i00], ny[i00 + 1], ny[i00 + NG + 1], ny[i00 + NG + 2], tx, ty);
306
475
  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;
476
+ const fcol = (sx - ox) / lpw - c0;
477
+ const frow = (oy - sy) / lph - r0;
478
+ const o = (py * outW + px) * 4;
312
479
  if (pal) {
480
+ // Categorical: nearest-neighbor + color table.
481
+ const col = Math.floor(fcol), row = Math.floor(frow);
482
+ if (col < 0 || col >= ww || row < 0 || row >= hh) continue;
483
+ const v = bufs[0][row * ww + col];
484
+ if (!isFinite(v)) continue;
313
485
  const ci = v & 255;
314
- if (ci === 0 || (this.nodata != null && v === this.nodata)) continue;
315
- const o = (py * TILE + px) * 4;
486
+ // Transparency: the declared nodata when present, else the GDAL
487
+ // paletted convention that index 0 is the background/no-data class.
488
+ if (ndSet ? v === nodata : ci === 0) continue;
316
489
  out[o] = pal[ci * 4]; out[o + 1] = pal[ci * 4 + 1];
317
- out[o + 2] = pal[ci * 4 + 2]; out[o + 3] = 255;
490
+ out[o + 2] = pal[ci * 4 + 2]; out[o + 3] = alpha;
491
+ } else if (rgb) {
492
+ // RGB composite: bilinear-sample each band, rescale -> curve -> gamma.
493
+ let ok = true;
494
+ for (let k = 0; k < 3; k++) {
495
+ const v = sampleWindowBilinear(bufs[k], ww, hh, fcol, frow);
496
+ if (!isFinite(v) || (ndSet && v === nodata)) { ok = false; break; }
497
+ const [mn, mx] = rescales[k] || rescales[0];
498
+ const t = transferCurve(Math.max(0, Math.min(1, (v - mn) / ((mx - mn) || 1))), stretch, gamma);
499
+ out[o + k] = Math.round(t * 255);
500
+ }
501
+ if (ok) out[o + 3] = alpha;
502
+ else { out[o] = out[o + 1] = out[o + 2] = 0; }
318
503
  } else {
319
- grid[py * TILE + px] = v;
504
+ // Continuous single band: bilinear; colormap applied below.
505
+ const v = sampleWindowBilinear(bufs[0], ww, hh, fcol, frow);
506
+ if (!isFinite(v) || (ndSet && v === nodata)) continue;
507
+ grid[py * outW + px] = v;
320
508
  }
321
509
  }
322
510
  }
323
- if (pal) return out;
324
- return this.tiler.render(grid, TILE, TILE, min, max, colormap, true);
511
+ if (pal || rgb) return out;
512
+ const [mn, mx] = rescales[0];
513
+ // colorize returns a Uint8Array; expose a Uint8ClampedArray (zero-copy view,
514
+ // matching the palette/RGB branches and the RenderedImage type) so callers
515
+ // can pass it straight to ImageData.
516
+ const c = colorize(
517
+ grid, outW, outH, mn, mx, colormap, ndSet ? nodata : undefined, true,
518
+ stretch, gamma, reversed, opacity,
519
+ );
520
+ return new Uint8ClampedArray(c.buffer, c.byteOffset, c.length);
521
+ }
522
+
523
+ // --- TiTiler-style read API ----------------------------------------------
524
+
525
+ /** XYZ zoom whose tile resolution matches the full-res pixel size. */
526
+ _maxzoom() {
527
+ const res = Math.abs(this.gt[1]);
528
+ return Math.max(0, Math.min(24, Math.round(Math.log2((2 * OS) / (TILE * res)))));
529
+ }
530
+
531
+ /** XYZ zoom whose tile resolution matches the coarsest overview. */
532
+ _minzoom() {
533
+ const c = this.levels[this.levels.length - 1];
534
+ const res = Math.abs(this.gt[1]) * (this.levels[0].width / c.width);
535
+ return Math.max(0, Math.min(24, Math.round(Math.log2((2 * OS) / (TILE * res)))));
536
+ }
537
+
538
+ /** Dataset info (≈ TiTiler `/cog/info`). */
539
+ info() {
540
+ const l0 = this.levels[0];
541
+ const b = this.boundsLonLat;
542
+ return {
543
+ bounds: b, // WGS84 [minlon, minlat, maxlon, maxlat]
544
+ crs: this.crsLabel,
545
+ width: l0.width,
546
+ height: l0.height,
547
+ count: l0.bands,
548
+ dtype: dtypeOf(l0),
549
+ nodata: this.nodata == null || Number.isNaN(this.nodata) ? null : this.nodata,
550
+ colorinterp: this.palette ? ["palette"] : null,
551
+ overviews: this.levels.length - 1,
552
+ tile_size: [l0.tile_width, l0.tile_height],
553
+ minzoom: this._minzoom(),
554
+ maxzoom: this._maxzoom(),
555
+ band_descriptions: Array.from({ length: l0.bands }, (_, i) => `b${i + 1}`),
556
+ compression: l0.compression,
557
+ };
558
+ }
559
+
560
+ /** Dataset info as a GeoJSON Feature (≈ TiTiler `/cog/info.geojson`). */
561
+ infoGeoJSON() {
562
+ const [w, s, e, n] = this.boundsLonLat;
563
+ return {
564
+ type: "Feature",
565
+ geometry: {
566
+ type: "Polygon",
567
+ coordinates: [[[w, s], [e, s], [e, n], [w, n], [w, s]]],
568
+ },
569
+ properties: this.info(),
570
+ };
571
+ }
572
+
573
+ /** Mapbox TileJSON document (≈ TiTiler `/cog/tilejson.json`). */
574
+ tilejson({ tilesUrl = "cog://{z}/{x}/{y}", minzoom, maxzoom, scheme = "xyz" } = {}) {
575
+ const b = this.boundsLonLat;
576
+ const mn = minzoom ?? this._minzoom();
577
+ return {
578
+ tilejson: "2.2.0",
579
+ version: "1.0.0",
580
+ scheme,
581
+ tiles: [tilesUrl],
582
+ minzoom: mn,
583
+ maxzoom: maxzoom ?? this._maxzoom(),
584
+ bounds: b,
585
+ center: [(b[0] + b[2]) / 2, (b[1] + b[3]) / 2, mn],
586
+ };
587
+ }
588
+
589
+ /** Band value(s) at a WGS84 lon/lat (≈ TiTiler `/cog/point/{lon},{lat}`). */
590
+ async point(lon, lat, { bidx } = {}) {
591
+ const [mx, my] = proj4("EPSG:4326", "EPSG:3857").forward([lon, lat]);
592
+ const [sx, sy] = this.toSource.forward([mx, my]); // source CRS coords
593
+ const l0 = this.levels[0];
594
+ const col = Math.floor((sx - this.gt[0]) / Math.abs(this.gt[1]));
595
+ const row = Math.floor((this.gt[3] - sy) / Math.abs(this.gt[5]));
596
+ if (col < 0 || col >= l0.width || row < 0 || row >= l0.height) {
597
+ return { coordinates: [lon, lat], values: [], band_names: [], outside: true };
598
+ }
599
+ const bands = bidx ? bidx.map((b) => b - 1) : Array.from({ length: l0.bands }, (_, i) => i);
600
+ let values;
601
+ if (this.planar) {
602
+ // Planar: whitebox can't address bands 1..n; read the pixel via geotiff.js.
603
+ const img = await this._tiffImage(0);
604
+ const r = await img.readRasters({ window: [col, row, col + 1, row + 1], samples: bands });
605
+ values = r.map((b) => b[0]);
606
+ } else {
607
+ const tcol = Math.floor(col / l0.tile_width), trow = Math.floor(row / l0.tile_height);
608
+ const [off, len] = Array.from(this.stream.tile_range(0, tcol, trow));
609
+ const px = await this._getTile(0, { col: tcol, row: trow, offset: off, length: len });
610
+ const base = ((row % l0.tile_height) * l0.tile_width + (col % l0.tile_width)) * l0.bands;
611
+ values = bands.map((b) => px[base + b]);
612
+ }
613
+ return {
614
+ coordinates: [lon, lat],
615
+ values,
616
+ band_names: bands.map((b) => `b${b + 1}`),
617
+ };
618
+ }
619
+
620
+ /** Per-band statistics (≈ TiTiler `/cog/statistics`), from a decimated
621
+ * overview (the largest one whose width is ≤ `maxSize`). */
622
+ async statistics({ maxSize = 1024 } = {}) {
623
+ let level = this.levels.length - 1;
624
+ for (let i = 0; i < this.levels.length; i++) {
625
+ if (this.levels[i].width <= maxSize) { level = i; break; }
626
+ }
627
+ const lv = this.levels[level];
628
+ const nodata = this.nodata == null || Number.isNaN(this.nodata) ? null : this.nodata;
629
+ const out = {};
630
+ for (let b = 0; b < lv.bands; b++) {
631
+ const buf = await this._assembleWindow(level, 0, 0, lv.width, lv.height, b);
632
+ out[`b${b + 1}`] = computeStats(buf, nodata);
633
+ }
634
+ return out;
635
+ }
636
+
637
+ /** Render a preview of the whole dataset (≈ TiTiler `/cog/preview`).
638
+ * Returns `{ width, height, rgba }`. `opts` accepts the render params
639
+ * (`bidx`, `min`/`max`/`rescale`, `colormap`, `nodata`) plus `maxSize` /
640
+ * `width` / `height`. */
641
+ async preview({ maxSize = 1024, width, height, ...render } = {}) {
642
+ const merc = mercExtentFromLonLat(this.boundsLonLat);
643
+ const [w, h] = fitSize(merc[2] - merc[0], merc[3] - merc[1], maxSize, width, height);
644
+ const rgba = (await this._renderExtent(merc, w, h, render)) || new Uint8ClampedArray(w * h * 4);
645
+ return { width: w, height: h, rgba };
646
+ }
647
+
648
+ /** Render a WGS84 bbox region (≈ TiTiler `/cog/bbox`). `bbox` is
649
+ * [minLon, minLat, maxLon, maxLat]. Returns `{ width, height, rgba }`. */
650
+ async bbox(bbox, { maxSize = 1024, width, height, ...render } = {}) {
651
+ const merc = mercExtentFromLonLat(bbox);
652
+ const [w, h] = fitSize(merc[2] - merc[0], merc[3] - merc[1], maxSize, width, height);
653
+ const rgba = (await this._renderExtent(merc, w, h, render)) || new Uint8ClampedArray(w * h * 4);
654
+ return { width: w, height: h, rgba };
655
+ }
656
+
657
+ /** Like {@link preview}, encoded as PNG bytes. */
658
+ async previewPNG(opts = {}) {
659
+ const { width, height, rgba } = await this.preview(opts);
660
+ return rgbaToPng(rgba, width, height);
661
+ }
662
+
663
+ /** Like {@link bbox}, encoded as PNG bytes. */
664
+ async bboxPNG(bbox, opts = {}) {
665
+ const r = await this.bbox(bbox, opts);
666
+ return rgbaToPng(r.rgba, r.width, r.height);
325
667
  }
326
668
  }
327
669
 
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);
670
+ /** Encode a `w`x`h` RGBA buffer to PNG bytes (browser; uses OffscreenCanvas). */
671
+ export async function rgbaToPng(rgba, w = TILE, h = TILE) {
672
+ const img = new ImageData(new Uint8ClampedArray(rgba), w, h);
673
+ const cv = new OffscreenCanvas(w, h);
332
674
  cv.getContext("2d").putImageData(img, 0, 0);
333
675
  const blob = await cv.convertToBlob({ type: "image/png" });
334
676
  return new Uint8Array(await blob.arrayBuffer());
335
677
  }
336
678
 
679
+ /** Names of the built-in colormaps (for single-band rendering). */
680
+ export function colormaps() {
681
+ return JSON.parse(colormap_names());
682
+ }
683
+
337
684
  /**
338
685
  * Register a MapLibre custom protocol (e.g. `cog://{z}/{x}/{y}`).
339
686
  * `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.1",
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",