@walkthru-earth/objex 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/README.md +3 -1
  2. package/dist/components/browser/FileBrowser.svelte +25 -14
  3. package/dist/components/browser/FileTreeSidebar.svelte +42 -6
  4. package/dist/components/layout/ConnectionDialog.svelte +100 -1
  5. package/dist/components/layout/Sidebar.svelte +43 -25
  6. package/dist/components/viewers/CodeViewer.svelte +23 -0
  7. package/dist/components/viewers/CogControls.svelte +208 -0
  8. package/dist/components/viewers/CogControls.svelte.d.ts +12 -0
  9. package/dist/components/viewers/CogViewer.svelte +353 -1160
  10. package/dist/components/viewers/CogViewer.svelte.d.ts +1 -1
  11. package/dist/components/viewers/DatabaseViewer.svelte +345 -37
  12. package/dist/components/viewers/MarkdownViewer.svelte +1 -1
  13. package/dist/components/viewers/TableViewer.svelte +123 -41
  14. package/dist/components/viewers/ZarrMapViewer.svelte +29 -0
  15. package/dist/components/viewers/ZarrViewer.svelte +1 -4
  16. package/dist/constants.d.ts +6 -2
  17. package/dist/constants.js +6 -2
  18. package/dist/file-icons/index.d.ts +1 -1
  19. package/dist/file-icons/index.js +12 -2
  20. package/dist/i18n/ar.js +24 -0
  21. package/dist/i18n/en.js +24 -0
  22. package/dist/i18n/index.svelte.d.ts +0 -1
  23. package/dist/i18n/index.svelte.js +0 -3
  24. package/dist/index.d.ts +2 -0
  25. package/dist/index.js +1 -0
  26. package/dist/query/engine.d.ts +20 -4
  27. package/dist/query/index.d.ts +2 -1
  28. package/dist/query/index.js +1 -0
  29. package/dist/query/source.d.ts +30 -0
  30. package/dist/query/source.js +37 -0
  31. package/dist/query/wasm.d.ts +7 -5
  32. package/dist/query/wasm.js +138 -85
  33. package/dist/storage/providers.d.ts +47 -0
  34. package/dist/storage/providers.js +160 -0
  35. package/dist/stores/files.svelte.d.ts +1 -2
  36. package/dist/stores/files.svelte.js +1 -2
  37. package/dist/stores/tabs.svelte.d.ts +9 -2
  38. package/dist/stores/tabs.svelte.js +11 -2
  39. package/dist/types.d.ts +11 -0
  40. package/dist/utils/cog.d.ts +244 -0
  41. package/dist/utils/cog.js +1039 -0
  42. package/dist/utils/deck.d.ts +0 -18
  43. package/dist/utils/deck.js +0 -36
  44. package/dist/utils/geometry-type.d.ts +52 -0
  45. package/dist/utils/geometry-type.js +76 -0
  46. package/dist/utils/markdown-sql.d.ts +1 -1
  47. package/dist/utils/markdown-sql.js +3 -4
  48. package/dist/utils/pmtiles-tile.d.ts +0 -2
  49. package/dist/utils/pmtiles-tile.js +0 -8
  50. package/dist/utils/url-state.d.ts +6 -0
  51. package/dist/utils/url-state.js +34 -26
  52. package/dist/utils/url.d.ts +13 -9
  53. package/dist/utils/url.js +16 -25
  54. package/dist/utils/zarr-tab.d.ts +22 -0
  55. package/dist/utils/zarr-tab.js +30 -0
  56. package/dist/utils/zarr.d.ts +0 -2
  57. package/dist/utils/zarr.js +73 -44
  58. package/package.json +47 -43
  59. package/dist/components/ui/tabs/index.d.ts +0 -5
  60. package/dist/components/ui/tabs/index.js +0 -7
  61. package/dist/components/ui/tabs/tabs-content.svelte +0 -17
  62. package/dist/components/ui/tabs/tabs-content.svelte.d.ts +0 -4
  63. package/dist/components/ui/tabs/tabs-list.svelte +0 -16
  64. package/dist/components/ui/tabs/tabs-list.svelte.d.ts +0 -4
  65. package/dist/components/ui/tabs/tabs-trigger.svelte +0 -20
  66. package/dist/components/ui/tabs/tabs-trigger.svelte.d.ts +0 -4
  67. package/dist/components/ui/tabs/tabs.svelte +0 -19
  68. package/dist/components/ui/tabs/tabs.svelte.d.ts +0 -4
  69. package/dist/components/viewers/MapViewer.svelte +0 -234
  70. package/dist/components/viewers/MapViewer.svelte.d.ts +0 -7
  71. package/dist/components/viewers/StyleEditorOverlay.svelte +0 -27
  72. package/dist/components/viewers/StyleEditorOverlay.svelte.d.ts +0 -7
@@ -0,0 +1,1039 @@
1
+ import { inferRenderPipeline } from '@developmentseed/deck.gl-geotiff';
2
+ import { LinearRescale } from '@developmentseed/deck.gl-raster/gpu-modules';
3
+ import loadEpsg from '@developmentseed/epsg/all';
4
+ import epsgCsvUrl from '@developmentseed/epsg/all.csv.gz?url';
5
+ import { GeoTIFF } from '@developmentseed/geotiff';
6
+ import { parseWkt } from '@developmentseed/proj';
7
+ import proj4Lib from 'proj4';
8
+ // ─── Constants ───────────────────────────────────────────────────
9
+ /** SampleFormat tag value → human label. */
10
+ export const SF_LABELS = {
11
+ 1: 'uint',
12
+ 2: 'int',
13
+ 3: 'float',
14
+ 4: 'void',
15
+ 5: 'complex int',
16
+ 6: 'complex float'
17
+ };
18
+ export const COLOR_RAMP_STOPS = {
19
+ grayscale: [
20
+ [0, 0, 0],
21
+ [255, 255, 255]
22
+ ],
23
+ terrain: [
24
+ [0, 0, 128],
25
+ [0, 100, 200],
26
+ [0, 154, 80],
27
+ [120, 180, 50],
28
+ [200, 170, 60],
29
+ [180, 120, 50],
30
+ [140, 90, 40],
31
+ [200, 200, 200],
32
+ [255, 255, 255]
33
+ ],
34
+ viridis: [
35
+ [68, 1, 84],
36
+ [72, 36, 117],
37
+ [64, 67, 135],
38
+ [52, 94, 141],
39
+ [33, 145, 140],
40
+ [43, 176, 127],
41
+ [95, 201, 97],
42
+ [186, 222, 39],
43
+ [253, 231, 37]
44
+ ],
45
+ magma: [
46
+ [0, 0, 4],
47
+ [22, 11, 57],
48
+ [67, 15, 98],
49
+ [114, 24, 114],
50
+ [161, 48, 104],
51
+ [206, 82, 83],
52
+ [237, 132, 62],
53
+ [251, 192, 75],
54
+ [252, 253, 191]
55
+ ],
56
+ turbo: [
57
+ [48, 18, 59],
58
+ [31, 82, 188],
59
+ [23, 158, 227],
60
+ [47, 212, 161],
61
+ [121, 238, 104],
62
+ [193, 241, 57],
63
+ [245, 206, 27],
64
+ [253, 141, 31],
65
+ [213, 47, 24]
66
+ ],
67
+ spectral: [
68
+ [158, 1, 66],
69
+ [213, 62, 79],
70
+ [244, 109, 67],
71
+ [253, 174, 97],
72
+ [254, 224, 139],
73
+ [255, 255, 191],
74
+ [230, 245, 152],
75
+ [171, 221, 164],
76
+ [94, 79, 162]
77
+ ]
78
+ };
79
+ /** Interpolate a normalized value (0..1) into an RGB color from a ramp. */
80
+ export function interpolateRamp(stops, t) {
81
+ const n = stops.length - 1;
82
+ const idx = Math.max(0, Math.min(n, t * n));
83
+ const lo = Math.floor(idx);
84
+ const hi = Math.min(lo + 1, n);
85
+ const f = idx - lo;
86
+ return [
87
+ Math.round(stops[lo][0] + f * (stops[hi][0] - stops[lo][0])),
88
+ Math.round(stops[lo][1] + f * (stops[hi][1] - stops[lo][1])),
89
+ Math.round(stops[lo][2] + f * (stops[hi][2] - stops[lo][2]))
90
+ ];
91
+ }
92
+ /** Generate a CSS linear-gradient string for a color ramp. */
93
+ export function rampToGradientCss(id) {
94
+ const stops = COLOR_RAMP_STOPS[id];
95
+ const colors = stops.map((s, i) => `rgb(${s[0]},${s[1]},${s[2]}) ${((i / (stops.length - 1)) * 100).toFixed(0)}%`);
96
+ return `linear-gradient(to right, ${colors.join(', ')})`;
97
+ }
98
+ /** Create a sensible default band config based on COG metadata. */
99
+ export function defaultBandConfig(bandCount, sampleFormat) {
100
+ if (bandCount >= 3) {
101
+ return {
102
+ mode: 'rgb',
103
+ rBand: 0,
104
+ gBand: 1,
105
+ bBand: 2,
106
+ band: 0,
107
+ colorRamp: 'viridis'
108
+ };
109
+ }
110
+ return {
111
+ mode: 'single',
112
+ rBand: 0,
113
+ gBand: 0,
114
+ bBand: 0,
115
+ band: 0,
116
+ colorRamp: sampleFormat === 2 || sampleFormat === 3 ? 'terrain' : 'viridis'
117
+ };
118
+ }
119
+ /** Check if the config matches the default for this COG (no user changes). */
120
+ export function isDefaultBandConfig(config, bandCount, sampleFormat) {
121
+ const def = defaultBandConfig(bandCount, sampleFormat);
122
+ return (config.mode === def.mode &&
123
+ config.rBand === def.rBand &&
124
+ config.gBand === def.gBand &&
125
+ config.bBand === def.bBand &&
126
+ config.band === def.band &&
127
+ config.colorRamp === def.colorRamp);
128
+ }
129
+ /**
130
+ * Inspect the TIFF tags that drive pipeline selection. Centralizes the
131
+ * Photometric.Palette === 3 magic number and the SampleFormat fallback in one
132
+ * place so viewers don't reimplement raw tag reads. Photometric values come
133
+ * from the @cogeotiff/core Photometric enum.
134
+ */
135
+ export function inspectCogTags(geotiff) {
136
+ const tags = geotiff.cachedTags;
137
+ const sampleFormat = tags.sampleFormat?.[0] ?? 1;
138
+ return {
139
+ sampleFormat,
140
+ isUint: sampleFormat === 1,
141
+ isPaletteIndexed: tags.photometric === 3 && Boolean(tags.colorMap)
142
+ };
143
+ }
144
+ /**
145
+ * Check if a given band config requires a custom pipeline (vs library default).
146
+ * Library default only works for uint with standard RGB band order, or for
147
+ * palette-indexed uint COGs where the embedded ColorMap tag auto-renders.
148
+ */
149
+ export function needsCustomPipelineForConfig(geotiff, config) {
150
+ const tags = geotiff.cachedTags;
151
+ const sf = tags.sampleFormat;
152
+ const isUint = sf !== null && sf[0] === 1;
153
+ if (!isUint)
154
+ return true;
155
+ // Palette-indexed uint COGs with an embedded ColorMap tag are auto-rendered
156
+ // by the library via its Colormap GPU module. Defer to the default pipeline
157
+ // only while the user has not changed the default band config.
158
+ // Photometric.Palette === 3 in @cogeotiff/core.
159
+ if (tags.photometric === 3 &&
160
+ tags.colorMap &&
161
+ isDefaultBandConfig(config, geotiff.count, sf[0])) {
162
+ return false;
163
+ }
164
+ if (config.mode === 'single')
165
+ return true;
166
+ if (config.rBand !== 0 || config.gBand !== 1 || config.bBand !== 2)
167
+ return true;
168
+ return false;
169
+ }
170
+ export const DEFAULT_RESCALE = { min: 0, max: 1 };
171
+ /** True when the rescale values would produce a visible change on the GPU. */
172
+ export function isRescaleActive(cfg) {
173
+ return cfg.min !== DEFAULT_RESCALE.min || cfg.max !== DEFAULT_RESCALE.max;
174
+ }
175
+ /**
176
+ * Build a `getTileData` + `renderTile` pair that reuses the library-default
177
+ * uint pipeline (via `inferRenderPipeline`) and appends `LinearRescale` to the
178
+ * returned render pipeline. Only safe to use when the default pipeline would
179
+ * have been chosen anyway, i.e. `needsCustomPipelineForConfig(geotiff, cfg)`
180
+ * is false. For non-uint or custom band configs the custom JS pipeline already
181
+ * bakes RGBA in CPU and a GPU rescale would be cosmetic.
182
+ *
183
+ * `inferRenderPipeline` needs the GPU `Device` which arrives in the first
184
+ * tile's `GetTileDataOptions`, so the pipeline is built lazily on first call.
185
+ */
186
+ export function createRescaledPipeline(geotiff, rescale) {
187
+ let builtFor = null;
188
+ let defaultGetTileData = null;
189
+ let defaultRenderTile = null;
190
+ function ensureBuilt(device) {
191
+ if (builtFor === device && defaultGetTileData && defaultRenderTile)
192
+ return;
193
+ const inferred = inferRenderPipeline(geotiff, device);
194
+ // `inferRenderPipeline` returns generic callbacks. `MinimalDataT` is the
195
+ // contractual superset used by COGLayer — safe upcast.
196
+ defaultGetTileData = inferred.getTileData;
197
+ defaultRenderTile = inferred.renderTile;
198
+ builtFor = device;
199
+ }
200
+ return {
201
+ getTileData: async (image, options) => {
202
+ ensureBuilt(options.device);
203
+ return defaultGetTileData(image, options);
204
+ },
205
+ renderTile: (data) => {
206
+ const base = defaultRenderTile(data);
207
+ const pipeline = base.renderPipeline ?? [];
208
+ return {
209
+ ...base,
210
+ renderPipeline: [
211
+ ...pipeline,
212
+ { module: LinearRescale, props: { rescaleMin: rescale.min, rescaleMax: rescale.max } }
213
+ ]
214
+ };
215
+ }
216
+ };
217
+ }
218
+ // ─── GeoTIFF normalization for COGLayer ──────────────────────────
219
+ // Web Mercator's safe latitude limit. EPSG:4326 bboxes outside ±85.051129° hit
220
+ // out-of-domain proj4 NaN when the library generates its tile matrix set.
221
+ const WM_LAT_LIMIT = 85.051129;
222
+ /**
223
+ * Apply the two upstream-bug workarounds a GeoTIFF needs before being handed
224
+ * to `COGLayer`:
225
+ * 1. Strip oversized overviews (image smaller than tile size). These produce
226
+ * out-of-domain proj4 NaN during pre-flight reprojection.
227
+ * 2. Clamp EPSG:4326 bbox to Web Mercator's safe range. Global 4326 COGs with
228
+ * ±90° extents crash the tile matrix generator.
229
+ *
230
+ * Mutates the GeoTIFF in place. Safe to call repeatedly. Kept out of the
231
+ * Svelte component so MultiCOG/Mosaic can apply the same fix per sub-COG.
232
+ */
233
+ export function normalizeCogGeotiff(geotiff) {
234
+ const validOverviews = geotiff.overviews.filter((ov) => ov.width >= ov.tileWidth && ov.height >= ov.tileHeight);
235
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
236
+ geotiff.overviews = validOverviews;
237
+ if (geotiff.crs === 4326) {
238
+ const [x0, y0, x1, y1] = geotiff.bbox;
239
+ const clamped = [
240
+ Math.max(x0, -180),
241
+ Math.max(y0, -WM_LAT_LIMIT),
242
+ Math.min(x1, 180),
243
+ Math.min(y1, WM_LAT_LIMIT)
244
+ ];
245
+ if (clamped[0] !== x0 || clamped[1] !== y0 || clamped[2] !== x1 || clamped[3] !== y1) {
246
+ Object.defineProperty(geotiff, 'bbox', {
247
+ value: clamped,
248
+ writable: false,
249
+ configurable: true
250
+ });
251
+ }
252
+ }
253
+ }
254
+ /**
255
+ * Decide which getTileData/renderTile pair COGLayer should use for a GeoTIFF.
256
+ * Four outcomes, in priority order:
257
+ *
258
+ * 1. Custom configurable (band swap, color ramp) — when bandConfig is active
259
+ * and needsCustomPipelineForConfig is true (non-uint, mode=single, or
260
+ * non-standard RGB band order).
261
+ * 2. Custom non-uint (Int/Float source) — when no bandConfig yet but the
262
+ * GeoTIFF itself forces custom handling.
263
+ * 3. Library default + LinearRescale — uint path is fine AND the user moved
264
+ * the rescale slider away from defaults.
265
+ * 4. Library default — returns `{}`, caller spreads into COGLayer props.
266
+ *
267
+ * Pure dispatch. Kept separate from the Svelte component so MultiCOG/Mosaic
268
+ * viewers can call it per sub-COG without re-implementing the decision tree.
269
+ */
270
+ export function selectCogPipeline(geotiff, opts = {}) {
271
+ const { bandConfig, rescale } = opts;
272
+ const useCustom = bandConfig
273
+ ? needsCustomPipelineForConfig(geotiff, bandConfig)
274
+ : needsCustomPipeline(geotiff);
275
+ if (useCustom && bandConfig) {
276
+ return {
277
+ getTileData: createConfigurableGetTileData(geotiff, bandConfig),
278
+ renderTile: customRenderTile
279
+ };
280
+ }
281
+ if (useCustom) {
282
+ return {
283
+ getTileData: createCustomGetTileData(geotiff),
284
+ renderTile: customRenderTile
285
+ };
286
+ }
287
+ if (rescale && isRescaleActive(rescale)) {
288
+ const pipeline = createRescaledPipeline(geotiff, rescale);
289
+ return {
290
+ getTileData: pipeline.getTileData,
291
+ renderTile: pipeline.renderTile
292
+ };
293
+ }
294
+ return {};
295
+ }
296
+ const BITMAP_SOURCE = 'geotiff-bitmap-src';
297
+ const BITMAP_LAYER = 'geotiff-bitmap-layer';
298
+ // ─── Pure helpers ────────────────────────────────────────────────
299
+ /** Safely clamp a number to a range, treating NaN/Infinity as the fallback. */
300
+ export function safeClamp(v, lo, hi, fallback) {
301
+ return Number.isFinite(v) ? Math.max(lo, Math.min(hi, v)) : fallback;
302
+ }
303
+ /** Clamp geographic bounds to valid MapLibre web-Mercator range. */
304
+ export function clampBounds(b) {
305
+ return {
306
+ west: safeClamp(b.west, -180, 180, -180),
307
+ south: safeClamp(b.south, -85.051129, 85.051129, -85.051129),
308
+ east: safeClamp(b.east, -180, 180, 180),
309
+ north: safeClamp(b.north, -85.051129, 85.051129, 85.051129)
310
+ };
311
+ }
312
+ /**
313
+ * Build a data-type label from GeoTIFF sample format and bits per sample.
314
+ * e.g. "uint8", "float32", "int16"
315
+ */
316
+ export function buildDataTypeLabel(sampleFormat, bitsPerSample) {
317
+ return `${SF_LABELS[sampleFormat] ?? `sf${sampleFormat}`}${bitsPerSample ?? ''}`;
318
+ }
319
+ // ─── Map helpers (depend on maplibre-gl) ─────────────────────────
320
+ /**
321
+ * Query the GPU's MAX_TEXTURE_SIZE from MapLibre's WebGL context.
322
+ * Falls back to 4096 (lowest common denominator for mobile GPUs).
323
+ */
324
+ export function getMaxTextureSize(map) {
325
+ try {
326
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
327
+ const gl = map.painter?.context?.gl;
328
+ if (gl)
329
+ return gl.getParameter(gl.MAX_TEXTURE_SIZE);
330
+ }
331
+ catch {
332
+ /* fallback */
333
+ }
334
+ return 4096;
335
+ }
336
+ /**
337
+ * Fit the map to COG bounds with responsive padding.
338
+ * Uses smaller padding on mobile to zoom in closer, ensuring overviews load
339
+ * properly instead of appearing black at very low zoom levels.
340
+ * After fitting, bumps zoom +2 when the viewport settles at a very low level.
341
+ */
342
+ export function fitCogBounds(map, b) {
343
+ const isMobile = window.innerWidth < 640;
344
+ const viewportMin = Math.min(window.innerWidth, window.innerHeight);
345
+ const padding = isMobile ? 5 : Math.max(10, Math.round(viewportMin * 0.04));
346
+ map.fitBounds([
347
+ [b.west, b.south],
348
+ [b.east, b.north]
349
+ ], { padding, maxZoom: 18, speed: 1.2, maxDuration: 2000 });
350
+ const lonSpan = b.east - b.west;
351
+ const latSpan = b.north - b.south;
352
+ const isLargeExtent = lonSpan > 90 || latSpan > 45;
353
+ if (!isLargeExtent) {
354
+ map.once('moveend', () => {
355
+ const z = map.getZoom();
356
+ const minZoom = isMobile ? 10 : 8;
357
+ if (z < minZoom) {
358
+ map.zoomTo(z + 2, { duration: 500 });
359
+ }
360
+ });
361
+ }
362
+ }
363
+ /** Remove the native bitmap source/layer from the map (idempotent). */
364
+ export function cleanupNativeBitmap(map) {
365
+ try {
366
+ if (map.getLayer(BITMAP_LAYER))
367
+ map.removeLayer(BITMAP_LAYER);
368
+ }
369
+ catch {
370
+ /* already removed */
371
+ }
372
+ try {
373
+ if (map.getSource(BITMAP_SOURCE))
374
+ map.removeSource(BITMAP_SOURCE);
375
+ }
376
+ catch {
377
+ /* already removed */
378
+ }
379
+ }
380
+ // ─── Terrain color ramp ──────────────────────────────────────────
381
+ /** Terrain-inspired color ramp: deep blue → green → brown → white. */
382
+ const TERRAIN_RAMP = [
383
+ [0, 0, 128], // deep water
384
+ [0, 100, 200], // shallow water
385
+ [0, 154, 80], // lowland green
386
+ [120, 180, 50], // mid green
387
+ [200, 170, 60], // savanna
388
+ [180, 120, 50], // brown
389
+ [140, 90, 40], // dark brown
390
+ [200, 200, 200], // rock
391
+ [255, 255, 255] // snow / peak
392
+ ];
393
+ /** Interpolate a 0..1 value into a terrain RGB color. */
394
+ function terrainColor(t) {
395
+ const n = TERRAIN_RAMP.length - 1;
396
+ const idx = Math.max(0, Math.min(n, t * n));
397
+ const lo = Math.floor(idx);
398
+ const hi = Math.min(lo + 1, n);
399
+ const f = idx - lo;
400
+ return [
401
+ Math.round(TERRAIN_RAMP[lo][0] + f * (TERRAIN_RAMP[hi][0] - TERRAIN_RAMP[lo][0])),
402
+ Math.round(TERRAIN_RAMP[lo][1] + f * (TERRAIN_RAMP[hi][1] - TERRAIN_RAMP[lo][1])),
403
+ Math.round(TERRAIN_RAMP[lo][2] + f * (TERRAIN_RAMP[hi][2] - TERRAIN_RAMP[lo][2]))
404
+ ];
405
+ }
406
+ // ─── Non-tiled bitmap rendering ──────────────────────────────────
407
+ /**
408
+ * Compute geographic bounds (WGS84) from a GeoTIFF's native CRS bbox
409
+ * using edge-sampling for accuracy with non-linear projections.
410
+ */
411
+ async function computeGeographicBounds(geotiff, signal) {
412
+ const crs = geotiff.crs;
413
+ const [x0, y0, x1, y1] = geotiff.bbox;
414
+ // EPSG:4326 — bbox is already geographic
415
+ if (crs === 4326) {
416
+ return clampBounds({ west: x0, south: y0, east: x1, north: y1 });
417
+ }
418
+ // For other CRS, resolve the projection definition from the bundled EPSG
419
+ // database (numeric codes) or fall back to ProjJSON.
420
+ let proj4Def;
421
+ if (typeof crs === 'number') {
422
+ const wkt = await lookupEpsgWkt(crs);
423
+ if (signal.aborted)
424
+ return null;
425
+ if (!wkt)
426
+ return null;
427
+ proj4Def = wkt;
428
+ }
429
+ else {
430
+ // ProjJson — try to pass directly to proj4 (limited support)
431
+ proj4Def = JSON.stringify(crs);
432
+ }
433
+ try {
434
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
435
+ const conv = proj4Lib(proj4Def, 'EPSG:4326');
436
+ const N = 4;
437
+ const pts = [];
438
+ for (let i = 0; i <= N; i++) {
439
+ const t = i / N;
440
+ pts.push([x0 + t * (x1 - x0), y0]);
441
+ pts.push([x0 + t * (x1 - x0), y1]);
442
+ pts.push([x0, y0 + t * (y1 - y0)]);
443
+ pts.push([x1, y0 + t * (y1 - y0)]);
444
+ }
445
+ let w = 180;
446
+ let s = 90;
447
+ let e = -180;
448
+ let n = -90;
449
+ for (const [px, py] of pts) {
450
+ const r = conv.forward([px, py], false);
451
+ if (Number.isFinite(r[0]) &&
452
+ Number.isFinite(r[1]) &&
453
+ Math.abs(r[0]) <= 180 &&
454
+ Math.abs(r[1]) <= 90) {
455
+ w = Math.min(w, r[0]);
456
+ e = Math.max(e, r[0]);
457
+ s = Math.min(s, r[1]);
458
+ n = Math.max(n, r[1]);
459
+ }
460
+ }
461
+ if (w < e && s < n) {
462
+ return clampBounds({ west: w, south: s, east: e, north: n });
463
+ }
464
+ }
465
+ catch {
466
+ /* proj4 conversion failed */
467
+ }
468
+ return null;
469
+ }
470
+ const MAX_NONTILED_PIXELS = 100_000_000;
471
+ /**
472
+ * Render a non-tiled GeoTIFF as a MapLibre native image source (bitmap).
473
+ * Opens the file with @developmentseed/geotiff, reads band 0, normalizes
474
+ * to grayscale RGBA, and adds to the map as a raster layer.
475
+ *
476
+ * Returns CogInfo for the metadata panel.
477
+ */
478
+ export async function renderNonTiledBitmap(options) {
479
+ const { url, map, signal } = options;
480
+ // Open GeoTIFF (reuse if already opened for pre-flight)
481
+ const geotiff = options.geotiff ?? (await GeoTIFF.fromUrl(url));
482
+ if (signal.aborted)
483
+ throw new DOMException('Aborted', 'AbortError');
484
+ const imgW = geotiff.width;
485
+ const imgH = geotiff.height;
486
+ const totalPixels = imgW * imgH;
487
+ const bandCount = geotiff.count;
488
+ const nodata = geotiff.nodata;
489
+ // Determine data type from the underlying TIFF image
490
+ const tiffImage = geotiff.image;
491
+ // @cogeotiff/core TiffImage stores tag values
492
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
493
+ const sampleFormat = tiffImage.value?.('SampleFormat')?.[0] ?? 1;
494
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
495
+ const bitsPerSample = tiffImage.value?.('BitsPerSample')?.[0] ?? 8;
496
+ const dataType = buildDataTypeLabel(sampleFormat, bitsPerSample);
497
+ // Compute geographic bounds
498
+ const geoBounds = await computeGeographicBounds(geotiff, signal);
499
+ if (signal.aborted)
500
+ throw new DOMException('Aborted', 'AbortError');
501
+ if (!geoBounds) {
502
+ throw new Error('Cannot determine geographic bounds for non-tiled GeoTIFF');
503
+ }
504
+ // Size gate
505
+ if (totalPixels > MAX_NONTILED_PIXELS) {
506
+ fitCogBounds(map, geoBounds);
507
+ throw new Error(`Non-tiled GeoTIFF too large (${imgW.toLocaleString()} \u00d7 ${imgH.toLocaleString()} = ` +
508
+ `${(totalPixels / 1e6).toFixed(0)}M pixels). Convert to COG: ` +
509
+ `gdal_translate -of COG input.tif output.tif`);
510
+ }
511
+ // Cap to GPU texture limit
512
+ const maxTexDim = getMaxTextureSize(map);
513
+ const needsDownsample = imgW > maxTexDim || imgH > maxTexDim;
514
+ let readW = imgW;
515
+ let readH = imgH;
516
+ if (needsDownsample) {
517
+ const scale = Math.min(maxTexDim / imgW, maxTexDim / imgH);
518
+ readW = Math.max(1, Math.round(imgW * scale));
519
+ readH = Math.max(1, Math.round(imgH * scale));
520
+ }
521
+ // Read band 0 via a single tile fetch (non-tiled → single strip)
522
+ // For non-tiled images, fetch tile (0,0) which returns the entire image
523
+ const tile = await geotiff.fetchTile(0, 0, { signal });
524
+ if (signal.aborted)
525
+ throw new DOMException('Aborted', 'AbortError');
526
+ const arr = tile.array;
527
+ const bandData = arr.layout === 'band-separate' ? arr.bands[0] : arr.data;
528
+ const isSingleBand = bandCount === 1;
529
+ // Compute min/max for linear stretch (with scale/offset applied)
530
+ let bMin = Infinity;
531
+ let bMax = -Infinity;
532
+ const len = Math.min(bandData.length, readW * readH);
533
+ for (let i = 0; i < len; i++) {
534
+ const raw = bandData[i];
535
+ if (nodata !== null && raw === nodata)
536
+ continue;
537
+ if (!Number.isFinite(raw))
538
+ continue;
539
+ const v = raw;
540
+ if (v < bMin)
541
+ bMin = v;
542
+ if (v > bMax)
543
+ bMax = v;
544
+ }
545
+ if (!Number.isFinite(bMin)) {
546
+ bMin = 0;
547
+ bMax = 1;
548
+ }
549
+ const bRange = bMax - bMin || 1;
550
+ // Normalize to RGBA (terrain color ramp for single-band int/float)
551
+ const pixelCount = len;
552
+ const useTerrainRamp = isSingleBand && (sampleFormat === 2 || sampleFormat === 3);
553
+ const rgba = new Uint8ClampedArray(pixelCount * 4);
554
+ for (let i = 0; i < pixelCount; i++) {
555
+ const raw = bandData[i];
556
+ const isND = (nodata !== null && raw === nodata) || !Number.isFinite(raw);
557
+ const idx = i * 4;
558
+ if (isND) {
559
+ rgba[idx] = 0;
560
+ rgba[idx + 1] = 0;
561
+ rgba[idx + 2] = 0;
562
+ rgba[idx + 3] = 0;
563
+ continue;
564
+ }
565
+ const t = Math.max(0, Math.min(1, (raw - bMin) / bRange));
566
+ if (useTerrainRamp) {
567
+ const [r, g, b] = terrainColor(t);
568
+ rgba[idx] = r;
569
+ rgba[idx + 1] = g;
570
+ rgba[idx + 2] = b;
571
+ }
572
+ else {
573
+ const gray = Math.round(t * 255);
574
+ rgba[idx] = gray;
575
+ rgba[idx + 1] = gray;
576
+ rgba[idx + 2] = gray;
577
+ }
578
+ rgba[idx + 3] = 255;
579
+ }
580
+ // Render to canvas → data URL
581
+ const canvas = document.createElement('canvas');
582
+ canvas.width = readW;
583
+ canvas.height = readH;
584
+ const ctx = canvas.getContext('2d');
585
+ ctx.putImageData(new ImageData(rgba, readW, readH), 0, 0);
586
+ const dataUrl = canvas.toDataURL();
587
+ canvas.width = 0;
588
+ canvas.height = 0;
589
+ // Add to MapLibre as native image source
590
+ cleanupNativeBitmap(map);
591
+ map.addSource(BITMAP_SOURCE, {
592
+ type: 'image',
593
+ url: dataUrl,
594
+ coordinates: [
595
+ [geoBounds.west, geoBounds.north],
596
+ [geoBounds.east, geoBounds.north],
597
+ [geoBounds.east, geoBounds.south],
598
+ [geoBounds.west, geoBounds.south]
599
+ ]
600
+ });
601
+ map.addLayer({
602
+ id: BITMAP_LAYER,
603
+ source: BITMAP_SOURCE,
604
+ type: 'raster',
605
+ paint: { 'raster-opacity': 1 }
606
+ });
607
+ return {
608
+ width: imgW,
609
+ height: imgH,
610
+ bandCount,
611
+ dataType,
612
+ bounds: geoBounds,
613
+ downsampled: needsDownsample
614
+ };
615
+ }
616
+ /**
617
+ * Check whether a GeoTIFF needs a custom render pipeline.
618
+ * v0.3's inferRenderPipeline only supports unsigned integers (SampleFormat 1).
619
+ * Signed int (2) and float (3) need custom getTileData/renderTile.
620
+ */
621
+ export function needsCustomPipeline(geotiff) {
622
+ const tags = geotiff.cachedTags;
623
+ const sf = tags.sampleFormat;
624
+ // sampleFormat is null or not uint → needs custom
625
+ return sf === null || sf[0] !== 1;
626
+ }
627
+ /**
628
+ * Create custom getTileData for non-uint COGs.
629
+ * Reads band 0, normalizes using GDAL statistics / per-tile adaptive stretch,
630
+ * applies terrain color ramp for single-band data.
631
+ */
632
+ export function createCustomGetTileData(geotiff) {
633
+ // Read Scale/Offset TIFF tags (GDAL convention for scaled datasets like DEMs)
634
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
635
+ const tags = geotiff.cachedTags;
636
+ // GDAL stores data-scaling Scale/Offset as TIFF tags (33550-area).
637
+ // @developmentseed/geotiff exposes them in cachedTags but the TS type
638
+ // doesn't declare them — access via any.
639
+ const gdalScale = tags.scale ?? null;
640
+ const gdalOffset = tags.offset ?? null;
641
+ const hasScaleOffset = gdalScale !== null && gdalOffset !== null && (gdalScale !== 1 || gdalOffset !== 0);
642
+ // Pre-compute normalization range from stored GDAL statistics if available
643
+ const stats = geotiff.storedStats;
644
+ let globalMin = null;
645
+ let globalMax = null;
646
+ if (stats) {
647
+ const band1Stats = stats.get(1); // GDAL uses 1-based indexing
648
+ if (band1Stats) {
649
+ globalMin = band1Stats.min ?? null;
650
+ globalMax = band1Stats.max ?? null;
651
+ }
652
+ }
653
+ // If we have global stats, apply scale/offset to get real-world units
654
+ if (globalMin !== null && globalMax !== null && hasScaleOffset) {
655
+ globalMin = globalMin * (gdalScale ?? 1) + (gdalOffset ?? 0);
656
+ globalMax = globalMax * (gdalScale ?? 1) + (gdalOffset ?? 0);
657
+ }
658
+ const bandCount = geotiff.count;
659
+ const sf = tags.sampleFormat?.[0] ?? 1;
660
+ const isSingleBand = bandCount === 1;
661
+ // Shared range across all tiles — when no GDAL stats exist, the first
662
+ // tile's scan seeds the range and subsequent tiles widen it. This
663
+ // eliminates visible seams between tiles caused by per-tile normalization.
664
+ let sharedMin = globalMin;
665
+ let sharedMax = globalMax;
666
+ return async (image, options) => {
667
+ const tile = await image.fetchTile(options.x, options.y, {
668
+ boundless: false,
669
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
670
+ pool: options.pool,
671
+ signal: options.signal
672
+ });
673
+ const arr = tile.array;
674
+ const { width, height, nodata } = arr;
675
+ const bandData = arr.layout === 'band-separate' ? arr.bands[0] : arr.data;
676
+ const pixelCount = width * height;
677
+ const scale = gdalScale ?? 1;
678
+ const offset = gdalOffset ?? 0;
679
+ // When no global stats, scan this tile and widen the shared range
680
+ if (sharedMin === null || sharedMax === null) {
681
+ let tMin = Infinity;
682
+ let tMax = -Infinity;
683
+ for (let i = 0; i < pixelCount; i++) {
684
+ const raw = bandData[i];
685
+ if (nodata !== null && raw === nodata)
686
+ continue;
687
+ if (!Number.isFinite(raw))
688
+ continue;
689
+ const v = hasScaleOffset ? raw * scale + offset : raw;
690
+ if (v < tMin)
691
+ tMin = v;
692
+ if (v > tMax)
693
+ tMax = v;
694
+ }
695
+ if (Number.isFinite(tMin)) {
696
+ sharedMin = tMin;
697
+ sharedMax = tMax;
698
+ }
699
+ else {
700
+ sharedMin = 0;
701
+ sharedMax = 1;
702
+ }
703
+ }
704
+ const rangeMin = sharedMin;
705
+ const rangeMax = sharedMax;
706
+ const range = rangeMax - rangeMin || 1;
707
+ // Render to RGBA
708
+ const rgba = new Uint8ClampedArray(pixelCount * 4);
709
+ for (let i = 0; i < pixelCount; i++) {
710
+ const raw = bandData[i];
711
+ const isND = (nodata !== null && raw === nodata) || !Number.isFinite(raw);
712
+ const idx = i * 4;
713
+ if (isND) {
714
+ rgba[idx] = 0;
715
+ rgba[idx + 1] = 0;
716
+ rgba[idx + 2] = 0;
717
+ rgba[idx + 3] = 0;
718
+ continue;
719
+ }
720
+ const v = hasScaleOffset ? raw * scale + offset : raw;
721
+ const t = Math.max(0, Math.min(1, (v - rangeMin) / range));
722
+ if (isSingleBand && (sf === 2 || sf === 3)) {
723
+ // Terrain color ramp for single-band int/float (likely elevation/DEM)
724
+ const [r, g, b] = terrainColor(t);
725
+ rgba[idx] = r;
726
+ rgba[idx + 1] = g;
727
+ rgba[idx + 2] = b;
728
+ }
729
+ else {
730
+ // Grayscale for multi-band or other types
731
+ const gray = Math.round(t * 255);
732
+ rgba[idx] = gray;
733
+ rgba[idx + 1] = gray;
734
+ rgba[idx + 2] = gray;
735
+ }
736
+ rgba[idx + 3] = 255;
737
+ }
738
+ return {
739
+ imageData: new ImageData(rgba, width, height),
740
+ width,
741
+ height
742
+ };
743
+ };
744
+ }
745
+ /**
746
+ * Custom renderTile for non-uint COGs.
747
+ * v0.5 RasterLayer requires a RenderTileResult with `image` or `renderPipeline`.
748
+ * We produce an ImageData and pass it through the `image` slot. deck.gl manages
749
+ * the texture lifecycle and prepends a CreateTexture module automatically.
750
+ */
751
+ export function customRenderTile(data) {
752
+ return { image: data.imageData };
753
+ }
754
+ // ─── Configurable custom pipeline ────────────────────────────────
755
+ /**
756
+ * Extract band data arrays from a raster tile.
757
+ * Returns an array of typed arrays, one per band.
758
+ */
759
+ function extractBands(arr, bandCount, pixelCount) {
760
+ if (arr.layout === 'band-separate' && arr.bands) {
761
+ return arr.bands;
762
+ }
763
+ // pixel-interleaved → split into per-band arrays
764
+ const data = arr.data;
765
+ const bands = [];
766
+ for (let b = 0; b < bandCount; b++) {
767
+ const band = new Float64Array(pixelCount);
768
+ for (let i = 0; i < pixelCount; i++) {
769
+ band[i] = data[i * bandCount + b];
770
+ }
771
+ bands.push(band);
772
+ }
773
+ return bands;
774
+ }
775
+ /**
776
+ * Compute per-band min/max from an array of band data.
777
+ * Returns [min[], max[]] for the requested band indices.
778
+ */
779
+ function computeBandRanges(bands, bandIndices, pixelCount, nodata) {
780
+ const mins = [];
781
+ const maxs = [];
782
+ for (const bi of bandIndices) {
783
+ const band = bands[bi];
784
+ if (!band) {
785
+ mins.push(0);
786
+ maxs.push(1);
787
+ continue;
788
+ }
789
+ let bMin = Infinity;
790
+ let bMax = -Infinity;
791
+ for (let i = 0; i < pixelCount; i++) {
792
+ const v = band[i];
793
+ if (nodata !== null && v === nodata)
794
+ continue;
795
+ if (!Number.isFinite(v))
796
+ continue;
797
+ if (v < bMin)
798
+ bMin = v;
799
+ if (v > bMax)
800
+ bMax = v;
801
+ }
802
+ mins.push(Number.isFinite(bMin) ? bMin : 0);
803
+ maxs.push(Number.isFinite(bMax) ? bMax : 1);
804
+ }
805
+ return { mins, maxs };
806
+ }
807
+ /**
808
+ * Create a configurable getTileData that respects BandConfig.
809
+ * Supports both RGB mode (multi-band → R,G,B) and single-band mode (color ramp).
810
+ */
811
+ export function createConfigurableGetTileData(geotiff, config) {
812
+ const bandCount = geotiff.count;
813
+ // Shared per-band ranges across tiles (seeded on first tile, widened by subsequent)
814
+ const sharedMins = new Map();
815
+ const sharedMaxs = new Map();
816
+ return async (image, options) => {
817
+ const tile = await image.fetchTile(options.x, options.y, {
818
+ boundless: false,
819
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
820
+ pool: options.pool,
821
+ signal: options.signal
822
+ });
823
+ const arr = tile.array;
824
+ const { width, height, nodata } = arr;
825
+ const pixelCount = width * height;
826
+ const bands = extractBands(arr, bandCount, pixelCount);
827
+ const rgba = new Uint8ClampedArray(pixelCount * 4);
828
+ if (config.mode === 'rgb') {
829
+ // RGB mode: map 3 bands to R, G, B
830
+ const indices = [config.rBand, config.gBand, config.bBand];
831
+ // Compute ranges for the 3 selected bands
832
+ for (const bi of indices) {
833
+ if (!sharedMins.has(bi)) {
834
+ const { mins, maxs } = computeBandRanges(bands, [bi], pixelCount, nodata);
835
+ sharedMins.set(bi, mins[0]);
836
+ sharedMaxs.set(bi, maxs[0]);
837
+ }
838
+ }
839
+ const rBand = bands[config.rBand];
840
+ const gBand = bands[config.gBand];
841
+ const bBand = bands[config.bBand];
842
+ const rMin = sharedMins.get(config.rBand);
843
+ const rMax = sharedMaxs.get(config.rBand);
844
+ const gMin = sharedMins.get(config.gBand);
845
+ const gMax = sharedMaxs.get(config.gBand);
846
+ const bMin = sharedMins.get(config.bBand);
847
+ const bMax = sharedMaxs.get(config.bBand);
848
+ const rRange = rMax - rMin || 1;
849
+ const gRange = gMax - gMin || 1;
850
+ const bRange = bMax - bMin || 1;
851
+ for (let i = 0; i < pixelCount; i++) {
852
+ const rv = rBand?.[i] ?? 0;
853
+ const gv = gBand?.[i] ?? 0;
854
+ const bv = bBand?.[i] ?? 0;
855
+ const isND = (nodata !== null && (rv === nodata || gv === nodata || bv === nodata)) ||
856
+ !Number.isFinite(rv);
857
+ const idx = i * 4;
858
+ if (isND) {
859
+ rgba[idx] = 0;
860
+ rgba[idx + 1] = 0;
861
+ rgba[idx + 2] = 0;
862
+ rgba[idx + 3] = 0;
863
+ }
864
+ else {
865
+ rgba[idx] = Math.round(Math.max(0, Math.min(1, (rv - rMin) / rRange)) * 255);
866
+ rgba[idx + 1] = Math.round(Math.max(0, Math.min(1, (gv - gMin) / gRange)) * 255);
867
+ rgba[idx + 2] = Math.round(Math.max(0, Math.min(1, (bv - bMin) / bRange)) * 255);
868
+ rgba[idx + 3] = 255;
869
+ }
870
+ }
871
+ }
872
+ else {
873
+ // Single-band mode: normalize + color ramp
874
+ const bi = config.band;
875
+ const bandData = bands[bi];
876
+ if (!sharedMins.has(bi) && bandData) {
877
+ const { mins, maxs } = computeBandRanges(bands, [bi], pixelCount, nodata);
878
+ sharedMins.set(bi, mins[0]);
879
+ sharedMaxs.set(bi, maxs[0]);
880
+ }
881
+ const rangeMin = sharedMins.get(bi) ?? 0;
882
+ const rangeMax = sharedMaxs.get(bi) ?? 1;
883
+ const range = rangeMax - rangeMin || 1;
884
+ const rampStops = COLOR_RAMP_STOPS[config.colorRamp];
885
+ for (let i = 0; i < pixelCount; i++) {
886
+ const raw = bandData?.[i] ?? 0;
887
+ const isND = (nodata !== null && raw === nodata) || !Number.isFinite(raw);
888
+ const idx = i * 4;
889
+ if (isND) {
890
+ rgba[idx] = 0;
891
+ rgba[idx + 1] = 0;
892
+ rgba[idx + 2] = 0;
893
+ rgba[idx + 3] = 0;
894
+ }
895
+ else {
896
+ const t = Math.max(0, Math.min(1, (raw - rangeMin) / range));
897
+ const [r, g, b] = interpolateRamp(rampStops, t);
898
+ rgba[idx] = r;
899
+ rgba[idx + 1] = g;
900
+ rgba[idx + 2] = b;
901
+ rgba[idx + 3] = 255;
902
+ }
903
+ }
904
+ }
905
+ return { imageData: new ImageData(rgba, width, height), width, height };
906
+ };
907
+ }
908
+ // ─── EPSG resolution via bundled database ────────────────────────
909
+ /**
910
+ * Look up the WKT string for an EPSG code from the bundled
911
+ * `@developmentseed/epsg` database. The CSV is streamed, gunzipped and parsed
912
+ * once on first use, subsequent lookups share the cached map via the
913
+ * `loadEpsg()` internal singleton promise.
914
+ */
915
+ async function lookupEpsgWkt(code) {
916
+ const db = await loadEpsg(epsgCsvUrl);
917
+ return db.get(code) ?? null;
918
+ }
919
+ // Units that `@developmentseed/proj` `metersPerUnit` accepts.
920
+ const ACCEPTED_CRS_UNITS = new Set([
921
+ 'm',
922
+ 'metre',
923
+ 'meter',
924
+ 'meters',
925
+ 'foot',
926
+ 'us survey foot',
927
+ 'degree'
928
+ ]);
929
+ /**
930
+ * Normalize a parsed projection definition so `generateTileMatrixSet` can
931
+ * compute metersPerUnit. wkt-parser sets `units = wkt.UNIT.name.toLowerCase()`
932
+ * and some EPSG WKT entries in the bundled database have a missing or
933
+ * non-standard UNIT node, which surfaces as `units = "unknown"` and a downstream
934
+ * throw. Infer the unit from `to_meter` or projection type when possible.
935
+ */
936
+ function normalizeCrsUnits(def) {
937
+ const current = def.units?.toLowerCase();
938
+ if (current && ACCEPTED_CRS_UNITS.has(current))
939
+ return def;
940
+ if (def.projName === 'longlat') {
941
+ def.units = 'degree';
942
+ return def;
943
+ }
944
+ const toMeter = def.to_meter;
945
+ if (toMeter === undefined || Math.abs(toMeter - 1) < 1e-9) {
946
+ def.units = 'meter';
947
+ }
948
+ else if (Math.abs(toMeter - 0.3048) < 1e-9) {
949
+ def.units = 'foot';
950
+ }
951
+ else if (Math.abs(toMeter - 1200 / 3937) < 1e-9) {
952
+ def.units = 'us survey foot';
953
+ }
954
+ return def;
955
+ }
956
+ /**
957
+ * Create an async EPSG resolver for `@developmentseed/deck.gl-geotiff`.
958
+ * Looks up the numeric EPSG code in the bundled WKT database and returns the
959
+ * `ProjectionDefinition` produced by `parseWkt`. Throws a clear error when the
960
+ * code is not present in the database.
961
+ */
962
+ export function createEpsgResolver() {
963
+ const cache = new Map();
964
+ return async (code) => {
965
+ const cached = cache.get(code);
966
+ if (cached)
967
+ return cached;
968
+ const wkt = await lookupEpsgWkt(code);
969
+ if (!wkt) {
970
+ throw new Error(`EPSG:${code} not found in bundled projection database`);
971
+ }
972
+ const def = normalizeCrsUnits(parseWkt(wkt));
973
+ cache.set(code, def);
974
+ return def;
975
+ };
976
+ }
977
+ /**
978
+ * Resolve a proj4-compatible definition for a CRS read from a GeoTIFF.
979
+ * For numeric EPSG codes this returns the WKT string from the bundled EPSG
980
+ * database, which `proj4()` accepts directly. For ProjJSON it falls back to a
981
+ * JSON string. Returns null for EPSG:4326 (no conversion needed) or when the
982
+ * code is not present in the database.
983
+ */
984
+ export async function resolveProj4Def(crs, _signal) {
985
+ if (crs === 4326)
986
+ return null;
987
+ if (typeof crs === 'number') {
988
+ return lookupEpsgWkt(crs);
989
+ }
990
+ // ProjJSON — stringify for proj4
991
+ return JSON.stringify(crs);
992
+ }
993
+ /**
994
+ * Read pixel values at a given lng/lat from a GeoTIFF.
995
+ * Converts WGS84 → source CRS → pixel coords, fetches the tile, reads all bands.
996
+ */
997
+ export async function readPixelAtLngLat(geotiff, lng, lat, proj4Def,
998
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
999
+ pool, signal) {
1000
+ // Convert WGS84 to source CRS
1001
+ let srcX = lng;
1002
+ let srcY = lat;
1003
+ if (proj4Def) {
1004
+ try {
1005
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1006
+ const conv = proj4Lib(proj4Def, 'EPSG:4326');
1007
+ [srcX, srcY] = conv.inverse([lng, lat]);
1008
+ }
1009
+ catch {
1010
+ return null;
1011
+ }
1012
+ }
1013
+ // Get pixel indices (row, col)
1014
+ const [row, col] = geotiff.index(srcX, srcY);
1015
+ if (row < 0 || row >= geotiff.height || col < 0 || col >= geotiff.width)
1016
+ return null;
1017
+ // Compute tile indices
1018
+ const tileX = Math.floor(col / geotiff.tileWidth);
1019
+ const tileY = Math.floor(row / geotiff.tileHeight);
1020
+ // Fetch tile
1021
+ const tile = await geotiff.fetchTile(tileX, tileY, { pool, signal });
1022
+ const arr = tile.array;
1023
+ // Read all band values at this pixel
1024
+ const localCol = col - tileX * arr.width;
1025
+ const localRow = row - tileY * arr.height;
1026
+ const pixelIndex = localRow * arr.width + localCol;
1027
+ const values = [];
1028
+ if (arr.layout === 'band-separate') {
1029
+ for (let b = 0; b < arr.count; b++) {
1030
+ values.push(arr.bands[b][pixelIndex]);
1031
+ }
1032
+ }
1033
+ else {
1034
+ for (let b = 0; b < arr.count; b++) {
1035
+ values.push(arr.data[pixelIndex * arr.count + b]);
1036
+ }
1037
+ }
1038
+ return { lng, lat, values, row, col };
1039
+ }