@walkthru-earth/objex 1.3.0 → 1.4.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.
Files changed (182) hide show
  1. package/LICENSE +5 -0
  2. package/README.md +20 -12
  3. package/dist/components/browser/FileTreeSidebar.svelte +32 -17
  4. package/dist/components/layout/AboutSheet.svelte +5 -2
  5. package/dist/components/layout/ConnectionDialog.svelte +1 -1
  6. package/dist/components/layout/SettingsSheet.svelte +237 -0
  7. package/dist/components/layout/SettingsSheet.svelte.d.ts +6 -0
  8. package/dist/components/layout/Sidebar.svelte +73 -6
  9. package/dist/components/layout/Sidebar.svelte.d.ts +4 -1
  10. package/dist/components/layout/StatusBar.svelte +1 -1
  11. package/dist/components/layout/TabBar.svelte +2 -2
  12. package/dist/components/ui/context-menu/context-menu-radio-group.svelte.d.ts +1 -1
  13. package/dist/components/ui/dropdown-menu/dropdown-menu-checkbox-group.svelte.d.ts +1 -1
  14. package/dist/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte.d.ts +1 -1
  15. package/dist/components/ui/input/input.svelte.d.ts +1 -1
  16. package/dist/components/ui/resizable/index.d.ts +1 -1
  17. package/dist/components/ui/resizable/index.js +2 -2
  18. package/dist/components/ui/slider/index.d.ts +3 -0
  19. package/dist/components/ui/slider/index.js +5 -0
  20. package/dist/components/ui/slider/range-slider.svelte +94 -0
  21. package/dist/components/ui/slider/range-slider.svelte.d.ts +21 -0
  22. package/dist/components/ui/slider/slider.svelte +83 -0
  23. package/dist/components/ui/slider/slider.svelte.d.ts +7 -0
  24. package/dist/components/viewers/ArchiveViewer.svelte +2 -2
  25. package/dist/components/viewers/CodeViewer.svelte +31 -22
  26. package/dist/components/viewers/CogControls.svelte +338 -184
  27. package/dist/components/viewers/CogControls.svelte.d.ts +33 -10
  28. package/dist/components/viewers/CogViewer.svelte +320 -119
  29. package/dist/components/viewers/CopcViewer.svelte +1 -1
  30. package/dist/components/viewers/FlatGeobufViewer.svelte +1 -1
  31. package/dist/components/viewers/GeoParquetMapViewer.svelte +6 -6
  32. package/dist/components/viewers/GeoParquetMapViewer.svelte.d.ts +1 -1
  33. package/dist/components/viewers/ImageViewer.svelte +2 -2
  34. package/dist/components/viewers/MarkdownViewer.svelte +12 -9
  35. package/dist/components/viewers/MediaViewer.svelte +2 -2
  36. package/dist/components/viewers/ModelViewer.svelte +1 -1
  37. package/dist/components/viewers/MultiCogViewer.svelte +467 -102
  38. package/dist/components/viewers/MultiCogViewer.svelte.d.ts +1 -1
  39. package/dist/components/viewers/NotebookViewer.svelte +6 -3
  40. package/dist/components/viewers/PdfViewer.svelte +2 -2
  41. package/dist/components/viewers/PmtilesViewer.svelte +3 -6
  42. package/dist/components/viewers/RawViewer.svelte +6 -3
  43. package/dist/components/viewers/StacMapViewer.svelte +10 -2
  44. package/dist/components/viewers/StacMosaicViewer.svelte +1800 -362
  45. package/dist/components/viewers/StacMosaicViewer.svelte.d.ts +1 -1
  46. package/dist/components/viewers/StacTabViewer.svelte +24 -13
  47. package/dist/components/viewers/StacTabViewer.svelte.d.ts +1 -1
  48. package/dist/components/viewers/TableGrid.svelte +4 -4
  49. package/dist/components/viewers/TableStatusBar.svelte +1 -1
  50. package/dist/components/viewers/TableToolbar.svelte +1 -1
  51. package/dist/components/viewers/TableViewer.svelte +25 -17
  52. package/dist/components/viewers/TableViewer.svelte.d.ts +1 -0
  53. package/dist/components/viewers/ViewerRouter.svelte +16 -8
  54. package/dist/components/viewers/ZarrMapViewer.svelte +11 -9
  55. package/dist/components/viewers/ZarrViewer.svelte +4 -4
  56. package/dist/components/viewers/cog/ChannelPicker.svelte +83 -0
  57. package/dist/components/viewers/cog/ChannelPicker.svelte.d.ts +13 -0
  58. package/dist/components/viewers/cog/PixelInspectorPanel.svelte +87 -0
  59. package/dist/components/viewers/cog/PixelInspectorPanel.svelte.d.ts +17 -0
  60. package/dist/components/viewers/cog/buildRgbLayer.d.ts +78 -0
  61. package/dist/components/viewers/cog/buildRgbLayer.js +176 -0
  62. package/dist/components/viewers/map/AttributeTable.svelte +1 -1
  63. package/dist/components/viewers/map/MapContainer.svelte +37 -11
  64. package/dist/components/viewers/pmtiles/PmtilesArchiveView.svelte +1 -1
  65. package/dist/components/viewers/pmtiles/PmtilesTileInspector.svelte +1 -1
  66. package/dist/components/viewers/stac/StacDatetimeBar.svelte +175 -0
  67. package/dist/components/viewers/stac/StacDatetimeBar.svelte.d.ts +10 -0
  68. package/dist/components/viewers/stac/StacFilterPanel.svelte +243 -0
  69. package/dist/components/viewers/stac/StacFilterPanel.svelte.d.ts +14 -0
  70. package/dist/components/viewers/stac/StacItemInspector.svelte +223 -0
  71. package/dist/components/viewers/stac/StacItemInspector.svelte.d.ts +10 -0
  72. package/dist/components/viewers/stac/StacItemStrip.svelte +228 -0
  73. package/dist/components/viewers/stac/StacItemStrip.svelte.d.ts +12 -0
  74. package/dist/file-icons/index.d.ts +1 -1
  75. package/dist/file-icons/index.js +1 -1
  76. package/dist/i18n/ar.js +110 -2
  77. package/dist/i18n/en.js +110 -2
  78. package/dist/index.d.ts +2 -28
  79. package/dist/index.js +7 -23
  80. package/dist/query/engine.d.ts +10 -0
  81. package/dist/query/source.js +1 -1
  82. package/dist/query/stac-source-factory.d.ts +65 -0
  83. package/dist/query/stac-source-factory.js +77 -0
  84. package/dist/query/stac-source-parquet.d.ts +135 -0
  85. package/dist/query/stac-source-parquet.js +465 -0
  86. package/dist/query/wasm.d.ts +8 -0
  87. package/dist/query/wasm.js +304 -2
  88. package/dist/storage/presign.js +1 -1
  89. package/dist/storage/providers.js +5 -5
  90. package/dist/stores/config.svelte.d.ts +15 -0
  91. package/dist/stores/config.svelte.js +46 -0
  92. package/dist/stores/connections.svelte.d.ts +2 -2
  93. package/dist/stores/connections.svelte.js +1 -2
  94. package/dist/stores/files.svelte.d.ts +1 -1
  95. package/dist/stores/files.svelte.js +1 -1
  96. package/dist/stores/query-history.svelte.js +1 -1
  97. package/dist/stores/settings.svelte.d.ts +16 -1
  98. package/dist/stores/settings.svelte.js +104 -48
  99. package/dist/stores/tabs.svelte.d.ts +3 -0
  100. package/dist/stores/tabs.svelte.js +17 -0
  101. package/dist/utils/cog-histogram.d.ts +121 -0
  102. package/dist/utils/cog-histogram.js +424 -0
  103. package/dist/utils/cog.d.ts +200 -60
  104. package/dist/utils/cog.js +377 -114
  105. package/dist/utils/colormap-sprite.d.ts +0 -9
  106. package/dist/utils/colormap-sprite.js +0 -21
  107. package/dist/utils/deck.d.ts +16 -12
  108. package/dist/utils/deck.js +10 -4
  109. package/dist/utils/pmtiles-tile.js +2 -2
  110. package/dist/utils/{url.d.ts → signed-url.d.ts} +15 -1
  111. package/dist/utils/{url.js → signed-url.js} +32 -10
  112. package/dist/utils/url-state.d.ts +36 -0
  113. package/dist/utils/url-state.js +72 -2
  114. package/dist/utils/zarr-tab.d.ts +1 -2
  115. package/dist/utils/zarr-tab.js +1 -2
  116. package/dist/utils/zarr.d.ts +0 -17
  117. package/dist/utils/zarr.js +1 -45
  118. package/package.json +55 -84
  119. package/dist/components/browser/Breadcrumb.svelte +0 -50
  120. package/dist/components/browser/Breadcrumb.svelte.d.ts +0 -7
  121. package/dist/components/browser/CreateFolderDialog.svelte +0 -98
  122. package/dist/components/browser/CreateFolderDialog.svelte.d.ts +0 -6
  123. package/dist/components/browser/DeleteConfirmDialog.svelte +0 -90
  124. package/dist/components/browser/DeleteConfirmDialog.svelte.d.ts +0 -8
  125. package/dist/components/browser/DropZone.svelte +0 -83
  126. package/dist/components/browser/DropZone.svelte.d.ts +0 -7
  127. package/dist/components/browser/FileBrowser.svelte +0 -252
  128. package/dist/components/browser/FileBrowser.svelte.d.ts +0 -3
  129. package/dist/components/browser/FileRow.svelte +0 -117
  130. package/dist/components/browser/FileRow.svelte.d.ts +0 -9
  131. package/dist/components/browser/RenameDialog.svelte +0 -101
  132. package/dist/components/browser/RenameDialog.svelte.d.ts +0 -8
  133. package/dist/components/browser/SearchBar.svelte +0 -40
  134. package/dist/components/browser/SearchBar.svelte.d.ts +0 -6
  135. package/dist/components/browser/UploadButton.svelte +0 -65
  136. package/dist/components/browser/UploadButton.svelte.d.ts +0 -3
  137. package/dist/query/stac-geoparquet.d.ts +0 -31
  138. package/dist/query/stac-geoparquet.js +0 -136
  139. package/dist/utils/clipboard.d.ts +0 -13
  140. package/dist/utils/clipboard.js +0 -38
  141. package/dist/utils/cloud-url.d.ts +0 -27
  142. package/dist/utils/cloud-url.js +0 -61
  143. package/dist/utils/column-types.d.ts +0 -5
  144. package/dist/utils/column-types.js +0 -137
  145. package/dist/utils/connection-identity.d.ts +0 -51
  146. package/dist/utils/connection-identity.js +0 -97
  147. package/dist/utils/error.d.ts +0 -8
  148. package/dist/utils/error.js +0 -12
  149. package/dist/utils/evidence-context.d.ts +0 -22
  150. package/dist/utils/evidence-context.js +0 -56
  151. package/dist/utils/export.d.ts +0 -22
  152. package/dist/utils/export.js +0 -76
  153. package/dist/utils/file-sort.d.ts +0 -20
  154. package/dist/utils/file-sort.js +0 -41
  155. package/dist/utils/format.d.ts +0 -24
  156. package/dist/utils/format.js +0 -78
  157. package/dist/utils/geoarrow.d.ts +0 -32
  158. package/dist/utils/geoarrow.js +0 -672
  159. package/dist/utils/geometry-type.d.ts +0 -52
  160. package/dist/utils/geometry-type.js +0 -76
  161. package/dist/utils/hex.d.ts +0 -10
  162. package/dist/utils/hex.js +0 -27
  163. package/dist/utils/host-detection.d.ts +0 -23
  164. package/dist/utils/host-detection.js +0 -95
  165. package/dist/utils/local-storage.d.ts +0 -16
  166. package/dist/utils/local-storage.js +0 -37
  167. package/dist/utils/markdown-sql.d.ts +0 -30
  168. package/dist/utils/markdown-sql.js +0 -72
  169. package/dist/utils/notebook.d.ts +0 -59
  170. package/dist/utils/notebook.js +0 -211
  171. package/dist/utils/parquet-metadata.d.ts +0 -64
  172. package/dist/utils/parquet-metadata.js +0 -262
  173. package/dist/utils/stac-geoparquet.d.ts +0 -90
  174. package/dist/utils/stac-geoparquet.js +0 -223
  175. package/dist/utils/stac-hydrate.d.ts +0 -38
  176. package/dist/utils/stac-hydrate.js +0 -243
  177. package/dist/utils/stac.d.ts +0 -136
  178. package/dist/utils/stac.js +0 -176
  179. package/dist/utils/storage-url.d.ts +0 -90
  180. package/dist/utils/storage-url.js +0 -568
  181. package/dist/utils/wkb.d.ts +0 -43
  182. package/dist/utils/wkb.js +0 -359
package/dist/utils/cog.js CHANGED
@@ -1,41 +1,65 @@
1
+ import { SourceCache, SourceChunk } from '@chunkd/middleware';
2
+ import { SourceView } from '@chunkd/source';
3
+ import { SourceHttp } from '@chunkd/source-http';
1
4
  import { inferRenderPipeline } from '@developmentseed/deck.gl-geotiff';
2
5
  import { Colormap, FilterNoDataVal, LinearRescale } from '@developmentseed/deck.gl-raster/gpu-modules';
3
6
  import loadEpsg from '@developmentseed/epsg/all';
4
7
  import epsgCsvUrl from '@developmentseed/epsg/all.csv.gz?url';
5
8
  import { GeoTIFF } from '@developmentseed/geotiff';
6
9
  import { parseWkt } from '@developmentseed/proj';
10
+ import { buildDataTypeLabel, clampBounds, SF_LABELS, safeClamp } from '@walkthru-earth/objex-utils';
7
11
  import proj4Lib from 'proj4';
12
+ import { HISTOGRAM_BINS, readGdalStats, streamHistogram } from './cog-histogram.js';
8
13
  import { COLORMAP_INDEX, getColormapTexture } from './colormap-sprite.js';
9
- // ─── Constants ───────────────────────────────────────────────────
10
14
  /**
11
- * Patches a GLSL ES 3.00 compile error in `@developmentseed/deck.gl-raster`
12
- * v0.6.0-alpha.1. The `Colormap` shader module injects
13
- * `uniform sampler2DArray colormapTexture;` without a precision qualifier,
14
- * which the Apple-GPU path of luma.gl's WebGL2 backend rejects with
15
- * `ERROR: 'sampler2DArray' : No precision specified`. In GLSL ES 3.00,
16
- * every sampler type other than `sampler2D`/`samplerCube` needs explicit
17
- * precision in fragment shaders.
15
+ * Open a `GeoTIFF` from a URL, priming `SourceHttp.metadata.size` before
16
+ * chunked reads start. Replaces `GeoTIFF.fromUrl` which skips the head and
17
+ * can leave size unset (chunkd#1666, stac-map#459).
18
18
  *
19
- * Chain this module immediately BEFORE `Colormap` in the renderPipeline so
20
- * the combined `fs:#decl` inject emits the precision declaration first,
21
- * then the sampler uniform. Remove once upstream ships the precision fix.
19
+ * Primer strategy depends on whether the URL is SigV4-query-string signed.
20
+ * `presign.ts` signs URLs with `aws4fetch.signQuery({method: 'GET'})`, which
21
+ * binds the HTTP method into the signature, so a bare `HEAD` against a
22
+ * `GET`-signed URL 403s with `SignatureDoesNotMatch`. For signed URLs we
23
+ * use a 1-byte `Range: bytes=0-0` GET that returns `Content-Range:
24
+ * bytes 0-0/<TOTAL>`. For unsigned public URLs we use `source.head()`,
25
+ * which returns `Content-Length: <TOTAL>` directly.
26
+ *
27
+ * Why the split: the Range primer's total-size depends on the
28
+ * `Content-Range` response header being CORS-exposed via
29
+ * `Access-Control-Expose-Headers`. GCS public buckets only expose
30
+ * `Content-Length` and a handful of `X-Goog-*` headers (NOT
31
+ * `Content-Range`), so `response.headers.get('content-range')` returns
32
+ * null in the browser, `SourceHttp.metadata.size` falls back to
33
+ * `Content-Length` = 1, and `Tiff.readHeader` throws "offset is outside
34
+ * the bounds of the DataView" on the first IFD read past byte 0. HEAD
35
+ * avoids this entirely because `Content-Length` is a CORS-safelisted
36
+ * response header.
22
37
  */
23
- const Sampler2DArrayPrecision = {
24
- name: 'sampler2darray-precision',
25
- fs: '',
26
- inject: {
27
- 'fs:#decl': 'precision highp sampler2DArray;\n'
38
+ function isSignedQueryUrl(href) {
39
+ try {
40
+ const url = new URL(href);
41
+ const params = url.searchParams;
42
+ return params.has('X-Amz-Signature') || params.has('X-Goog-Signature') || params.has('sig');
28
43
  }
29
- };
30
- /** SampleFormat tag value → human label. */
31
- export const SF_LABELS = {
32
- 1: 'uint',
33
- 2: 'int',
34
- 3: 'float',
35
- 4: 'void',
36
- 5: 'complex int',
37
- 6: 'complex float'
38
- };
44
+ catch {
45
+ return false;
46
+ }
47
+ }
48
+ export async function loadGeoTIFF(href, options = {}) {
49
+ const { chunkSize = 32 * 1024, cacheSize = 1024 * 1024 } = options;
50
+ const source = new SourceHttp(href, {});
51
+ if (isSignedQueryUrl(href)) {
52
+ await source.fetch(0, 1);
53
+ }
54
+ else {
55
+ await source.head();
56
+ }
57
+ const chunk = new SourceChunk({ size: chunkSize });
58
+ const cache = new SourceCache({ size: cacheSize });
59
+ const view = new SourceView(source, [chunk, cache]);
60
+ return await GeoTIFF.open({ dataSource: source, headerSource: view });
61
+ }
62
+ export { buildDataTypeLabel, clampBounds, HISTOGRAM_BINS, readGdalStats, SF_LABELS, safeClamp, streamHistogram };
39
63
  /** Create a sensible default band config based on COG metadata. */
40
64
  export function defaultBandConfig(bandCount, sampleFormat) {
41
65
  if (bandCount >= 3 && bandCount <= 4) {
@@ -120,11 +144,183 @@ export function needsCustomPipelineForConfig(geotiff, config) {
120
144
  return true;
121
145
  return false;
122
146
  }
147
+ export const DEFAULT_NODATA_CONFIG = { mode: 'auto' };
148
+ /**
149
+ * Read the GDAL_NODATA tag from a GeoTIFF.
150
+ *
151
+ * GDAL convention: the tag is an ASCII string serialized via `Number()`; the
152
+ * literal `"nan"` (case-insensitive) round-trips to JS `NaN`. The library's
153
+ * `geotiff.nodata` getter already performs that `Number(rawString)` conversion,
154
+ * so this helper just exposes the resulting `number | null` (preserving `NaN`)
155
+ * behind a stable name for callers that want to drive `NodataConfig`.
156
+ */
157
+ export function readGdalNodata(geotiff) {
158
+ return geotiff.nodata;
159
+ }
160
+ /**
161
+ * Custom shader module that discards pixels whose `color.r` is NaN. Float COGs
162
+ * commonly encode nodata as NaN, but the shipped `FilterNoDataVal` does a
163
+ * `color.r == nodata.value` comparison which IEEE 754 always returns false for
164
+ * NaN. Mirrors the reference implementation in `src/render/shader-modules.ts`.
165
+ *
166
+ * Slots into a `RasterModule[]` render pipeline the same way as the upstream
167
+ * modules (e.g. `LinearRescale`).
168
+ */
169
+ // Typed as RasterModule['module'] (a luma.gl ShaderModule) so it composes
170
+ // with the upstream-shipped modules. No uniforms / no getUniforms needed.
171
+ export const FilterNaN = {
172
+ name: 'filterNaN',
173
+ inject: {
174
+ 'fs:DECKGL_FILTER_COLOR': /* glsl */ `
175
+ if (isnan(color.r)) {
176
+ discard;
177
+ }
178
+ `
179
+ }
180
+ };
181
+ /**
182
+ * Pick the correct nodata-discard module for a resolved nodata value.
183
+ *
184
+ * - `NaN` → `FilterNaN` (IEEE 754: `x == NaN` is always false).
185
+ * - finite number → `FilterNoDataVal { value: nodata / sampleScale }`.
186
+ * - `null` / non-finite (e.g. ±Infinity) → no module (nodata off).
187
+ *
188
+ * `sampleScale` divides the raw nodata value to put it in the same coordinate
189
+ * space the shader sees after hardware normalization (e.g. 255 for r8unorm,
190
+ * 65535 for r16unorm). Pass `1` when the shader receives raw float values.
191
+ */
192
+ export function nodataModule(nodata, sampleScale = 1) {
193
+ if (nodata === null)
194
+ return null;
195
+ if (Number.isNaN(nodata))
196
+ return { module: FilterNaN };
197
+ if (!Number.isFinite(nodata))
198
+ return null;
199
+ return {
200
+ module: FilterNoDataVal,
201
+ props: { value: nodata / sampleScale }
202
+ };
203
+ }
204
+ /** Resolve a tri-state `NodataConfig` against the auto-detected GDAL_NODATA value. */
205
+ export function resolveNodata(cfg, autoNodata) {
206
+ if (cfg.mode === 'off')
207
+ return null;
208
+ if (cfg.mode === 'value')
209
+ return cfg.value ?? null;
210
+ return autoNodata;
211
+ }
123
212
  export const DEFAULT_RESCALE = { min: 0, max: 1 };
124
213
  /** True when the rescale values would produce a visible change on the GPU. */
125
214
  export function isRescaleActive(cfg) {
126
215
  return cfg.min !== DEFAULT_RESCALE.min || cfg.max !== DEFAULT_RESCALE.max;
127
216
  }
217
+ /**
218
+ * Pick a sensible default `RescaleConfig` for a freshly opened COG. The slider
219
+ * operates in normalized shader space [0, 1], but the GPU's hardware
220
+ * normalization (`r8unorm` / `r16unorm` in `MultiCOGLayer`, or the library
221
+ * default uint pipeline elsewhere) collapses raw integer values onto that
222
+ * range by dividing by the format's max (255 for uint8, 65535 for uint16).
223
+ *
224
+ * For uint8 visual COGs (Sentinel-2 `visual` TCI, NAIP `image`) the natural
225
+ * land brightness sits around raw 50-100, so `max: 0.3` (≈ raw 76) gives a
226
+ * nicely contrasted preview. For uint16 reflectance bands (Sentinel-2 raw
227
+ * `nir` / `swir16` / `red`, Landsat C2 L2 `*_B*`) typical land surfaces sit at
228
+ * raw 800-3000 (reflectance × 10000), which is `0.012-0.046` after r16unorm.
229
+ * `max: 0.3` would render those near-black; `max: 0.05` (≈ raw 3277) keeps
230
+ * vegetation, soil, and water in the visible range while leaving headroom for
231
+ * brighter targets.
232
+ *
233
+ * Float / int sample formats fall back to the conservative `{0, 1}` no-op so
234
+ * the user can dial in their own range via the slider.
235
+ */
236
+ export function defaultRescaleForGeotiff(geotiff) {
237
+ const tags = geotiff.cachedTags;
238
+ const sampleFormat = tags.sampleFormat?.[0] ?? 1;
239
+ if (sampleFormat !== 1)
240
+ return { ...DEFAULT_RESCALE };
241
+ const bps = tags.bitsPerSample?.[0] ?? 8;
242
+ if (bps <= 8)
243
+ return { min: 0, max: 0.3 };
244
+ return { min: 0, max: 0.05 };
245
+ }
246
+ /**
247
+ * Build a 64-bin histogram of band 0 from a GeoTIFF's smallest overview, in
248
+ * the same shader-space [0, 1] coordinate system the rescale slider operates
249
+ * on (raw / 65535 for uint16, raw / 255 for uint8, raw clamped to [0, 1] for
250
+ * float). Used by viewers on the multi-asset MultiCOGLayer path to give the
251
+ * rescale slider a histogram backdrop without hooking per-tile sampling into
252
+ * the layer.
253
+ *
254
+ * Returns null if the smallest overview cannot be fetched. Skips the GeoTIFF's
255
+ * declared nodata value and non-finite values.
256
+ */
257
+ /**
258
+ * Walk a cumulative histogram (`HISTOGRAM_BIN_COUNT` bins covering [0, 1])
259
+ * and return the shader-space value at percentile `p` (0..1). Returns null
260
+ * when the histogram is empty. Linearly interpolates within the matching bin
261
+ * so the result is monotonic across calls with adjacent percentiles, instead
262
+ * of jumping in `1/HISTOGRAM_BIN_COUNT` increments.
263
+ */
264
+ export function percentileFromHistogram(histogram, p) {
265
+ if (!histogram || histogram.length !== HISTOGRAM_BIN_COUNT)
266
+ return null;
267
+ let total = 0;
268
+ for (let i = 0; i < HISTOGRAM_BIN_COUNT; i++)
269
+ total += histogram[i];
270
+ if (total === 0)
271
+ return null;
272
+ const target = total * Math.max(0, Math.min(1, p));
273
+ let acc = 0;
274
+ for (let i = 0; i < HISTOGRAM_BIN_COUNT; i++) {
275
+ const next = acc + histogram[i];
276
+ if (next >= target) {
277
+ const frac = histogram[i] === 0 ? 0 : (target - acc) / histogram[i];
278
+ return (i + frac) / HISTOGRAM_BIN_COUNT;
279
+ }
280
+ acc = next;
281
+ }
282
+ return 1;
283
+ }
284
+ export async function buildHistogramFromGeotiff(geotiff, signal) {
285
+ const tags = geotiff.cachedTags;
286
+ const sampleFormat = tags.sampleFormat?.[0] ?? 1;
287
+ const bps = tags.bitsPerSample?.[0] ?? 8;
288
+ const norm = sampleFormat === 1 ? (bps <= 8 ? 255 : 65535) : 1;
289
+ const nodata = geotiff.nodata;
290
+ const overviews = geotiff.overviews ?? [];
291
+ const sourceImage = overviews.length ? overviews[overviews.length - 1] : geotiff;
292
+ try {
293
+ const tile = await sourceImage.fetchTile(0, 0, { signal });
294
+ if (signal?.aborted)
295
+ return null;
296
+ const arr = tile.array;
297
+ const data = arr.layout === 'band-separate' ? arr.bands[0] : arr.data;
298
+ const stride = arr.layout === 'band-separate' ? 1 : (arr.count ?? 1);
299
+ const histogram = new Uint32Array(HISTOGRAM_BIN_COUNT);
300
+ let counted = 0;
301
+ const len = data.length;
302
+ for (let i = 0; i < len; i += stride) {
303
+ const raw = data[i];
304
+ if (!Number.isFinite(raw))
305
+ continue;
306
+ if (nodata !== null && raw === nodata)
307
+ continue;
308
+ const t = sampleFormat === 1 ? raw / norm : Math.max(0, Math.min(1, raw));
309
+ if (t < 0 || t > 1)
310
+ continue;
311
+ const bin = Math.min(HISTOGRAM_BIN_COUNT - 1, Math.floor(t * HISTOGRAM_BIN_COUNT));
312
+ histogram[bin]++;
313
+ counted++;
314
+ }
315
+ if (counted === 0)
316
+ return null;
317
+ return histogram;
318
+ }
319
+ catch {
320
+ /* swallow */
321
+ return null;
322
+ }
323
+ }
128
324
  /**
129
325
  * Build a `getTileData` + `renderTile` pair that reuses the library-default
130
326
  * uint pipeline (via `inferRenderPipeline`) and appends `LinearRescale` to the
@@ -144,7 +340,7 @@ export function createRescaledPipeline(geotiff, rescale) {
144
340
  if (builtFor === device && defaultGetTileData && defaultRenderTile)
145
341
  return;
146
342
  const inferred = inferRenderPipeline(geotiff, device);
147
- // `inferRenderPipeline` returns generic callbacks. `MinimalDataT` is the
343
+ // `inferRenderPipeline` returns generic callbacks. `MinimalTileData` is the
148
344
  // contractual superset used by COGLayer — safe upcast.
149
345
  defaultGetTileData = inferred.getTileData;
150
346
  defaultRenderTile = inferred.renderTile;
@@ -170,17 +366,18 @@ export function createRescaledPipeline(geotiff, rescale) {
170
366
  }
171
367
  /**
172
368
  * Build a `renderPipeline` array for `MultiCOGLayer` / raster mosaics.
173
- * Combines optional `FilterNoDataVal` + `LinearRescale` stages in the order
174
- * the GPU expects (no-data mask first, then rescale).
369
+ * Combines an optional nodata-discard module + `LinearRescale` in the order
370
+ * the GPU expects (no-data mask first, then rescale). The nodata module is
371
+ * selected by `nodataModule()` so NaN sentinels route through `FilterNaN`
372
+ * instead of the always-false `==` comparison in `FilterNoDataVal`.
175
373
  */
176
374
  export function buildBandRenderPipeline(opts = {}) {
177
375
  const modules = [];
178
- if (opts.noDataVal !== undefined && opts.noDataVal !== null) {
179
- modules.push({
180
- module: FilterNoDataVal,
181
- props: { noDataVal: opts.noDataVal }
182
- });
183
- }
376
+ const nodataMod = opts.noDataVal !== undefined
377
+ ? nodataModule(opts.noDataVal ?? null, opts.noDataSampleScale ?? 1)
378
+ : null;
379
+ if (nodataMod)
380
+ modules.push(nodataMod);
184
381
  if (opts.rescale && isRescaleActive(opts.rescale)) {
185
382
  modules.push({
186
383
  module: LinearRescale,
@@ -247,10 +444,11 @@ export function selectCogPipeline(geotiff, opts = {}) {
247
444
  ? needsCustomPipelineForConfig(geotiff, bandConfig)
248
445
  : needsCustomPipeline(geotiff);
249
446
  if (useCustom && bandConfig) {
447
+ // Note: callers that want identity-stable getTileData across config
448
+ // swaps should call `createConfigurableGetTileData` directly and reuse
449
+ // the loader, rather than re-running selectCogPipeline.
250
450
  return {
251
- getTileData: createConfigurableGetTileData(geotiff, bandConfig, {
252
- onHistogram: opts.onHistogram
253
- }),
451
+ getTileData: createConfigurableGetTileData(geotiff, bandConfig).getTileData,
254
452
  renderTile: buildCustomRenderTile(bandConfig, rescale)
255
453
  };
256
454
  }
@@ -260,7 +458,7 @@ export function selectCogPipeline(geotiff, opts = {}) {
260
458
  const fallbackSf = geotiff.cachedTags.sampleFormat?.[0] ?? 1;
261
459
  const fallbackConfig = defaultBandConfig(geotiff.count, fallbackSf);
262
460
  return {
263
- getTileData: createCustomGetTileData(geotiff, { onHistogram: opts.onHistogram }),
461
+ getTileData: createCustomGetTileData(geotiff),
264
462
  renderTile: buildCustomRenderTile(fallbackConfig, rescale)
265
463
  };
266
464
  }
@@ -275,27 +473,9 @@ export function selectCogPipeline(geotiff, opts = {}) {
275
473
  }
276
474
  const BITMAP_SOURCE = 'geotiff-bitmap-src';
277
475
  const BITMAP_LAYER = 'geotiff-bitmap-layer';
278
- // ─── Pure helpers ────────────────────────────────────────────────
279
- /** Safely clamp a number to a range, treating NaN/Infinity as the fallback. */
280
- export function safeClamp(v, lo, hi, fallback) {
281
- return Number.isFinite(v) ? Math.max(lo, Math.min(hi, v)) : fallback;
282
- }
283
- /** Clamp geographic bounds to valid MapLibre web-Mercator range. */
284
- export function clampBounds(b) {
285
- return {
286
- west: safeClamp(b.west, -180, 180, -180),
287
- south: safeClamp(b.south, -85.051129, 85.051129, -85.051129),
288
- east: safeClamp(b.east, -180, 180, 180),
289
- north: safeClamp(b.north, -85.051129, 85.051129, 85.051129)
290
- };
291
- }
292
- /**
293
- * Build a data-type label from GeoTIFF sample format and bits per sample.
294
- * e.g. "uint8", "float32", "int16"
295
- */
296
- export function buildDataTypeLabel(sampleFormat, bitsPerSample) {
297
- return `${SF_LABELS[sampleFormat] ?? `sf${sampleFormat}`}${bitsPerSample ?? ''}`;
298
- }
476
+ // ─── Types & pure helpers ────────────────────────────────────────
477
+ // `GeoBounds`, `CogInfo`, `safeClamp`, `clampBounds`, `buildDataTypeLabel`
478
+ // live in `cog-info.ts` in @walkthru-earth/objex-utils and are re-exported at the top of this file.
299
479
  // ─── Map helpers (depend on maplibre-gl) ─────────────────────────
300
480
  /**
301
481
  * Query the GPU's MAX_TEXTURE_SIZE from MapLibre's WebGL context.
@@ -458,7 +638,7 @@ const MAX_NONTILED_PIXELS = 100_000_000;
458
638
  export async function renderNonTiledBitmap(options) {
459
639
  const { url, map, signal } = options;
460
640
  // Open GeoTIFF (reuse if already opened for pre-flight)
461
- const geotiff = options.geotiff ?? (await GeoTIFF.fromUrl(url));
641
+ const geotiff = options.geotiff ?? (await loadGeoTIFF(url));
462
642
  if (signal.aborted)
463
643
  throw new DOMException('Aborted', 'AbortError');
464
644
  const imgW = geotiff.width;
@@ -604,8 +784,15 @@ export function needsCustomPipeline(geotiff) {
604
784
  // sampleFormat is null or not uint → needs custom
605
785
  return sf === null || sf[0] !== 1;
606
786
  }
607
- /** Number of histogram buckets produced by the CPU bake. */
608
- export const HISTOGRAM_BIN_COUNT = 64;
787
+ /**
788
+ * Number of histogram buckets produced by the CPU bake.
789
+ *
790
+ * Canonical value lives in `./cog-histogram.ts` as `HISTOGRAM_BINS`; this
791
+ * is kept as an alias so existing CogViewer / StacMosaicViewer imports
792
+ * continue to compile. Both names resolve to the same number — see
793
+ * `./cog-histogram.ts` for the streaming histogram + GDAL stats reader.
794
+ */
795
+ export const HISTOGRAM_BIN_COUNT = HISTOGRAM_BINS;
609
796
  /**
610
797
  * Create custom getTileData for non-uint COGs.
611
798
  * Reads band 0, normalizes using GDAL statistics / per-tile adaptive stretch,
@@ -614,7 +801,7 @@ export const HISTOGRAM_BIN_COUNT = 64;
614
801
  * `colormaps.png`. Reserves `r = 0` for nodata so `FilterNoDataVal` can
615
802
  * discard those fragments before the ramp sample.
616
803
  */
617
- export function createCustomGetTileData(geotiff, opts = {}) {
804
+ export function createCustomGetTileData(geotiff, _opts = {}) {
618
805
  // Read Scale/Offset TIFF tags (GDAL convention for scaled datasets like DEMs)
619
806
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
620
807
  const tags = geotiff.cachedTags;
@@ -649,7 +836,6 @@ export function createCustomGetTileData(geotiff, opts = {}) {
649
836
  let sharedMax = globalMax;
650
837
  // Resolve the sprite texture from the first tile's device; reuse per-device.
651
838
  let texturePromise = null;
652
- const histogram = isSingleBand ? new Uint32Array(HISTOGRAM_BIN_COUNT) : null;
653
839
  return async (image, options) => {
654
840
  if (isSingleBand && !texturePromise) {
655
841
  texturePromise = getColormapTexture(options.device);
@@ -669,6 +855,10 @@ export function createCustomGetTileData(geotiff, opts = {}) {
669
855
  const pixelCount = width * height;
670
856
  const scale = gdalScale ?? 1;
671
857
  const offset = gdalOffset ?? 0;
858
+ // Allocate per-tile histogram so deck.gl's tile cache retains it with
859
+ // the tile object. The viewer sums histograms of visible tiles from
860
+ // TileLayer's `onViewportLoad` hook, no shared accumulator needed.
861
+ const histogram = isSingleBand ? new Uint32Array(HISTOGRAM_BIN_COUNT) : null;
672
862
  // When no global stats, scan this tile and widen the shared range
673
863
  if (sharedMin === null || sharedMax === null) {
674
864
  let tMin = Infinity;
@@ -734,14 +924,13 @@ export function createCustomGetTileData(geotiff, opts = {}) {
734
924
  }
735
925
  rgba[idx + 3] = 255;
736
926
  }
737
- if (histogram && opts.onHistogram)
738
- opts.onHistogram(histogram);
739
927
  return {
740
928
  imageData: new ImageData(rgba, width, height),
741
929
  width,
742
930
  height,
743
931
  colormapTexture: isSingleBand ? colormapTexture : undefined,
744
- nodataSentinel: isSingleBand ? 0 : undefined
932
+ nodataSentinel: isSingleBand ? 0 : undefined,
933
+ histogram: histogram ?? undefined
745
934
  };
746
935
  };
747
936
  }
@@ -775,11 +964,7 @@ export function buildCustomRenderTile(config, rescale) {
775
964
  props: { rescaleMin: rescale.min, rescaleMax: rescale.max }
776
965
  });
777
966
  }
778
- pipeline.push(
779
- // Precision shim must come before Colormap, its `fs:#decl` inject
780
- // declares `precision highp sampler2DArray;` so the subsequent
781
- // sampler uniform compiles on WebGL2 / Apple GPU.
782
- { module: Sampler2DArrayPrecision, props: {} }, {
967
+ pipeline.push({
783
968
  module: Colormap,
784
969
  props: {
785
970
  colormapTexture: data.colormapTexture,
@@ -848,18 +1033,27 @@ function computeBandRanges(bands, bandIndices, pixelCount, nodata) {
848
1033
  * Supports RGB mode (multi-band → R,G,B with alpha=255, fully baked) and
849
1034
  * single-band mode (band N normalized into the `r` channel; the ramp is
850
1035
  * applied downstream by the GPU `Colormap` module via `buildCustomRenderTile`).
1036
+ *
1037
+ * The returned `getTileData` reference is stable across `updateConfig` calls.
1038
+ * deck.gl's TileLayer treats a changed `getTileData` identity as a cache
1039
+ * invalidation, so reusing this loader across band/ramp swaps preserves tiles.
851
1040
  */
852
- export function createConfigurableGetTileData(geotiff, config, opts = {}) {
1041
+ export function createConfigurableGetTileData(geotiff, config, _opts = {}) {
853
1042
  const bandCount = geotiff.count;
854
- // Shared per-band ranges across tiles (seeded on first tile, widened by subsequent)
855
- const sharedMins = new Map();
856
- const sharedMaxs = new Map();
857
- // Resolve the sprite texture from the first tile's device; reuse per-device.
858
- let texturePromise = null;
859
- const histogram = config.mode === 'single' ? new Uint32Array(HISTOGRAM_BIN_COUNT) : null;
860
- return async (image, options) => {
861
- if (config.mode === 'single' && !texturePromise) {
862
- texturePromise = getColormapTexture(options.device);
1043
+ // Mutable refs read on every tile bake. Mutating in place keeps the closure
1044
+ // and therefore the function reference — stable across config swaps.
1045
+ const refs = {
1046
+ config,
1047
+ // Shared per-band ranges across tiles (seeded on first tile, widened by subsequent)
1048
+ sharedMins: new Map(),
1049
+ sharedMaxs: new Map(),
1050
+ // Resolve the sprite texture from the first tile's device; reuse per-device.
1051
+ texturePromise: null
1052
+ };
1053
+ const getTileData = async (image, options) => {
1054
+ const currentConfig = refs.config;
1055
+ if (currentConfig.mode === 'single' && !refs.texturePromise) {
1056
+ refs.texturePromise = getColormapTexture(options.device);
863
1057
  }
864
1058
  const [tile, colormapTexture] = await Promise.all([
865
1059
  image.fetchTile(options.x, options.y, {
@@ -868,33 +1062,38 @@ export function createConfigurableGetTileData(geotiff, config, opts = {}) {
868
1062
  pool: options.pool,
869
1063
  signal: options.signal
870
1064
  }),
871
- texturePromise ?? Promise.resolve(undefined)
1065
+ refs.texturePromise ?? Promise.resolve(undefined)
872
1066
  ]);
873
1067
  const arr = tile.array;
874
1068
  const { width, height, nodata } = arr;
875
1069
  const pixelCount = width * height;
876
1070
  const bands = extractBands(arr, bandCount, pixelCount);
877
1071
  const rgba = new Uint8ClampedArray(pixelCount * 4);
878
- if (config.mode === 'rgb') {
1072
+ // Per-tile histogram, cached by deck.gl's tile cache with the tile
1073
+ // object. Cloud-native by construction: at each zoom level, COG only
1074
+ // decodes the overview tiles that cover the viewport, so the summed
1075
+ // histogram naturally reflects "what the user is looking at right now".
1076
+ const histogram = currentConfig.mode === 'single' ? new Uint32Array(HISTOGRAM_BIN_COUNT) : null;
1077
+ if (currentConfig.mode === 'rgb') {
879
1078
  // RGB mode: map 3 bands to R, G, B
880
- const indices = [config.rBand, config.gBand, config.bBand];
1079
+ const indices = [currentConfig.rBand, currentConfig.gBand, currentConfig.bBand];
881
1080
  // Compute ranges for the 3 selected bands
882
1081
  for (const bi of indices) {
883
- if (!sharedMins.has(bi)) {
1082
+ if (!refs.sharedMins.has(bi)) {
884
1083
  const { mins, maxs } = computeBandRanges(bands, [bi], pixelCount, nodata);
885
- sharedMins.set(bi, mins[0]);
886
- sharedMaxs.set(bi, maxs[0]);
1084
+ refs.sharedMins.set(bi, mins[0]);
1085
+ refs.sharedMaxs.set(bi, maxs[0]);
887
1086
  }
888
1087
  }
889
- const rBand = bands[config.rBand];
890
- const gBand = bands[config.gBand];
891
- const bBand = bands[config.bBand];
892
- const rMin = sharedMins.get(config.rBand);
893
- const rMax = sharedMaxs.get(config.rBand);
894
- const gMin = sharedMins.get(config.gBand);
895
- const gMax = sharedMaxs.get(config.gBand);
896
- const bMin = sharedMins.get(config.bBand);
897
- const bMax = sharedMaxs.get(config.bBand);
1088
+ const rBand = bands[currentConfig.rBand];
1089
+ const gBand = bands[currentConfig.gBand];
1090
+ const bBand = bands[currentConfig.bBand];
1091
+ const rMin = refs.sharedMins.get(currentConfig.rBand);
1092
+ const rMax = refs.sharedMaxs.get(currentConfig.rBand);
1093
+ const gMin = refs.sharedMins.get(currentConfig.gBand);
1094
+ const gMax = refs.sharedMaxs.get(currentConfig.gBand);
1095
+ const bMin = refs.sharedMins.get(currentConfig.bBand);
1096
+ const bMax = refs.sharedMaxs.get(currentConfig.bBand);
898
1097
  const rRange = rMax - rMin || 1;
899
1098
  const gRange = gMax - gMin || 1;
900
1099
  const bRange = bMax - bMin || 1;
@@ -923,15 +1122,15 @@ export function createConfigurableGetTileData(geotiff, config, opts = {}) {
923
1122
  // Single-band mode: normalize the selected band into the `r`
924
1123
  // channel and reserve `r = 0` as a nodata sentinel that
925
1124
  // `FilterNoDataVal` discards before the `Colormap` GPU lookup.
926
- const bi = config.band;
1125
+ const bi = currentConfig.band;
927
1126
  const bandData = bands[bi];
928
- if (!sharedMins.has(bi) && bandData) {
1127
+ if (!refs.sharedMins.has(bi) && bandData) {
929
1128
  const { mins, maxs } = computeBandRanges(bands, [bi], pixelCount, nodata);
930
- sharedMins.set(bi, mins[0]);
931
- sharedMaxs.set(bi, maxs[0]);
1129
+ refs.sharedMins.set(bi, mins[0]);
1130
+ refs.sharedMaxs.set(bi, maxs[0]);
932
1131
  }
933
- const rangeMin = sharedMins.get(bi) ?? 0;
934
- const rangeMax = sharedMaxs.get(bi) ?? 1;
1132
+ const rangeMin = refs.sharedMins.get(bi) ?? 0;
1133
+ const rangeMax = refs.sharedMaxs.get(bi) ?? 1;
935
1134
  const range = rangeMax - rangeMin || 1;
936
1135
  for (let i = 0; i < pixelCount; i++) {
937
1136
  const raw = bandData?.[i] ?? 0;
@@ -955,17 +1154,34 @@ export function createConfigurableGetTileData(geotiff, config, opts = {}) {
955
1154
  }
956
1155
  }
957
1156
  }
958
- if (histogram && opts.onHistogram)
959
- opts.onHistogram(histogram);
960
1157
  }
961
1158
  return {
962
1159
  imageData: new ImageData(rgba, width, height),
963
1160
  width,
964
1161
  height,
965
- colormapTexture: config.mode === 'single' ? colormapTexture : undefined,
966
- nodataSentinel: config.mode === 'single' ? 0 : undefined
1162
+ colormapTexture: currentConfig.mode === 'single' ? colormapTexture : undefined,
1163
+ nodataSentinel: currentConfig.mode === 'single' ? 0 : undefined,
1164
+ histogram: histogram ?? undefined
967
1165
  };
968
1166
  };
1167
+ return {
1168
+ getTileData,
1169
+ updateConfig(next) {
1170
+ // Mode/band swap implies different per-band ranges; clear so the next
1171
+ // tile reseeds them. The function identity itself is preserved.
1172
+ const prev = refs.config;
1173
+ refs.config = next;
1174
+ const bandsChanged = prev.mode !== next.mode ||
1175
+ (prev.mode === 'rgb' &&
1176
+ next.mode === 'rgb' &&
1177
+ (prev.rBand !== next.rBand || prev.gBand !== next.gBand || prev.bBand !== next.bBand)) ||
1178
+ (prev.mode === 'single' && next.mode === 'single' && prev.band !== next.band);
1179
+ if (bandsChanged) {
1180
+ refs.sharedMins.clear();
1181
+ refs.sharedMaxs.clear();
1182
+ }
1183
+ }
1184
+ };
969
1185
  }
970
1186
  // ─── EPSG resolution via bundled database ────────────────────────
971
1187
  /**
@@ -1052,13 +1268,58 @@ export async function resolveProj4Def(crs, _signal) {
1052
1268
  // ProjJSON — stringify for proj4
1053
1269
  return JSON.stringify(crs);
1054
1270
  }
1271
+ /**
1272
+ * Standard Web Mercator ground resolution (meters per screen pixel) at a given
1273
+ * zoom and latitude. Equivalent to MapLibre's `metersPerPixel`. Use this to
1274
+ * pick a COG overview level that matches what's painted on screen.
1275
+ */
1276
+ export function mapResolutionMetersPerPixel(zoom, latitude) {
1277
+ return (156543.03392 * Math.cos((latitude * Math.PI) / 180)) / 2 ** zoom;
1278
+ }
1279
+ /**
1280
+ * Pick the coarsest overview whose pixel size is ≤ `targetMetersPerPixel`,
1281
+ * matching lazycogs' `_select_overview`. Walks `geotiff.overviews` in
1282
+ * finest → coarsest order (the documented sort) and returns the last entry
1283
+ * that still satisfies the constraint. Returns `null` to mean "use full
1284
+ * resolution" (either the COG has no overviews, the target is already finer
1285
+ * than native, or every overview is coarser than the target — never
1286
+ * upsample).
1287
+ *
1288
+ * The COG's affine `a` component is the X-pixel-size in the COG's native CRS
1289
+ * units. For Web Mercator and other meter-based CRSes this is meters/pixel
1290
+ * and lines up directly with `mapResolutionMetersPerPixel`. For degree-based
1291
+ * CRSes (EPSG:4326) the comparison is against degrees/pixel, so callers
1292
+ * should derive the target in the same units.
1293
+ */
1294
+ export function selectOverviewForResolution(geotiff, targetMetersPerPixel) {
1295
+ if (!geotiff.overviews.length)
1296
+ return null;
1297
+ const nativeRes = Math.abs(geotiff.transform[0]);
1298
+ if (targetMetersPerPixel <= nativeRes)
1299
+ return null;
1300
+ let selected = null;
1301
+ for (const overview of geotiff.overviews) {
1302
+ if (Math.abs(overview.transform[0]) <= targetMetersPerPixel) {
1303
+ selected = overview;
1304
+ }
1305
+ else {
1306
+ break;
1307
+ }
1308
+ }
1309
+ return selected;
1310
+ }
1055
1311
  /**
1056
1312
  * Read pixel values at a given lng/lat from a GeoTIFF.
1057
1313
  * Converts WGS84 → source CRS → pixel coords, fetches the tile, reads all bands.
1314
+ *
1315
+ * If `options.overview` is supplied, the read happens against that overview
1316
+ * level instead of the full-resolution image (so the inspected value matches
1317
+ * the overview deck.gl is currently painting on screen). Pass `null` /
1318
+ * `undefined` to keep the legacy full-resolution behaviour.
1058
1319
  */
1059
1320
  export async function readPixelAtLngLat(geotiff, lng, lat, proj4Def,
1060
1321
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
1061
- pool, signal) {
1322
+ pool, signal, options) {
1062
1323
  // Convert WGS84 to source CRS
1063
1324
  let srcX = lng;
1064
1325
  let srcY = lat;
@@ -1072,15 +1333,17 @@ pool, signal) {
1072
1333
  return null;
1073
1334
  }
1074
1335
  }
1336
+ // Read against the selected overview if one was supplied, else full res.
1337
+ const source = options?.overview ?? geotiff;
1075
1338
  // Get pixel indices (row, col)
1076
- const [row, col] = geotiff.index(srcX, srcY);
1077
- if (row < 0 || row >= geotiff.height || col < 0 || col >= geotiff.width)
1339
+ const [row, col] = source.index(srcX, srcY);
1340
+ if (row < 0 || row >= source.height || col < 0 || col >= source.width)
1078
1341
  return null;
1079
1342
  // Compute tile indices
1080
- const tileX = Math.floor(col / geotiff.tileWidth);
1081
- const tileY = Math.floor(row / geotiff.tileHeight);
1343
+ const tileX = Math.floor(col / source.tileWidth);
1344
+ const tileY = Math.floor(row / source.tileHeight);
1082
1345
  // Fetch tile
1083
- const tile = await geotiff.fetchTile(tileX, tileY, { pool, signal });
1346
+ const tile = await source.fetchTile(tileX, tileY, { pool, signal });
1084
1347
  const arr = tile.array;
1085
1348
  // Read all band values at this pixel
1086
1349
  const localCol = col - tileX * arr.width;