@walkthru-earth/objex 1.0.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.
- package/README.md +11 -2
- package/dist/components/browser/FileBrowser.svelte +41 -54
- package/dist/components/browser/FileTreeSidebar.svelte +43 -7
- package/dist/components/layout/ConnectionDialog.svelte +100 -1
- package/dist/components/layout/Sidebar.svelte +43 -25
- package/dist/components/viewers/CodeViewer.svelte +23 -0
- package/dist/components/viewers/CogControls.svelte +208 -0
- package/dist/components/viewers/CogControls.svelte.d.ts +12 -0
- package/dist/components/viewers/CogViewer.svelte +353 -1160
- package/dist/components/viewers/CogViewer.svelte.d.ts +1 -1
- package/dist/components/viewers/DatabaseViewer.svelte +345 -37
- package/dist/components/viewers/MarkdownViewer.svelte +1 -1
- package/dist/components/viewers/TableViewer.svelte +123 -41
- package/dist/components/viewers/ZarrMapViewer.svelte +29 -0
- package/dist/components/viewers/ZarrViewer.svelte +1 -4
- package/dist/constants.d.ts +6 -2
- package/dist/constants.js +6 -2
- package/dist/file-icons/index.d.ts +1 -1
- package/dist/file-icons/index.js +12 -2
- package/dist/i18n/ar.js +24 -0
- package/dist/i18n/en.js +24 -0
- package/dist/i18n/index.svelte.d.ts +0 -1
- package/dist/i18n/index.svelte.js +0 -3
- package/dist/index.d.ts +11 -0
- package/dist/index.js +10 -0
- package/dist/query/engine.d.ts +20 -4
- package/dist/query/index.d.ts +2 -1
- package/dist/query/index.js +1 -0
- package/dist/query/source.d.ts +30 -0
- package/dist/query/source.js +37 -0
- package/dist/query/wasm.d.ts +7 -5
- package/dist/query/wasm.js +138 -85
- package/dist/storage/providers.d.ts +47 -0
- package/dist/storage/providers.js +160 -0
- package/dist/stores/connections.svelte.js +5 -31
- package/dist/stores/files.svelte.d.ts +2 -8
- package/dist/stores/files.svelte.js +5 -38
- package/dist/stores/query-history.svelte.js +3 -25
- package/dist/stores/settings.svelte.d.ts +1 -0
- package/dist/stores/settings.svelte.js +10 -30
- package/dist/stores/tabs.svelte.d.ts +9 -2
- package/dist/stores/tabs.svelte.js +11 -2
- package/dist/types.d.ts +11 -0
- package/dist/utils/cloud-url.d.ts +27 -0
- package/dist/utils/cloud-url.js +61 -0
- package/dist/utils/cog.d.ts +244 -0
- package/dist/utils/cog.js +1039 -0
- package/dist/utils/deck.d.ts +0 -18
- package/dist/utils/deck.js +0 -36
- package/dist/utils/export.d.ts +22 -2
- package/dist/utils/export.js +35 -10
- package/dist/utils/file-sort.d.ts +20 -0
- package/dist/utils/file-sort.js +41 -0
- package/dist/utils/geometry-type.d.ts +52 -0
- package/dist/utils/geometry-type.js +76 -0
- package/dist/utils/local-storage.d.ts +16 -0
- package/dist/utils/local-storage.js +37 -0
- package/dist/utils/markdown-sql.d.ts +1 -1
- package/dist/utils/markdown-sql.js +3 -4
- package/dist/utils/pmtiles-tile.d.ts +0 -2
- package/dist/utils/pmtiles-tile.js +0 -8
- package/dist/utils/url-state.d.ts +6 -0
- package/dist/utils/url-state.js +34 -26
- package/dist/utils/url.d.ts +13 -25
- package/dist/utils/url.js +17 -78
- package/dist/utils/zarr-tab.d.ts +22 -0
- package/dist/utils/zarr-tab.js +30 -0
- package/dist/utils/zarr.d.ts +0 -2
- package/dist/utils/zarr.js +73 -44
- package/package.json +50 -46
- package/dist/components/ui/tabs/index.d.ts +0 -5
- package/dist/components/ui/tabs/index.js +0 -7
- package/dist/components/ui/tabs/tabs-content.svelte +0 -17
- package/dist/components/ui/tabs/tabs-content.svelte.d.ts +0 -4
- package/dist/components/ui/tabs/tabs-list.svelte +0 -16
- package/dist/components/ui/tabs/tabs-list.svelte.d.ts +0 -4
- package/dist/components/ui/tabs/tabs-trigger.svelte +0 -20
- package/dist/components/ui/tabs/tabs-trigger.svelte.d.ts +0 -4
- package/dist/components/ui/tabs/tabs.svelte +0 -19
- package/dist/components/ui/tabs/tabs.svelte.d.ts +0 -4
- package/dist/components/viewers/MapViewer.svelte +0 -234
- package/dist/components/viewers/MapViewer.svelte.d.ts +0 -7
- package/dist/components/viewers/StyleEditorOverlay.svelte +0 -27
- 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
|
+
}
|