@developmentseed/deck.gl-geotiff 0.4.0 → 0.5.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.
@@ -0,0 +1,649 @@
1
+ import { COORDINATE_SYSTEM, CompositeLayer } from "@deck.gl/core";
2
+ import { TileLayer } from "@deck.gl/geo-layers";
3
+ import { PathLayer, TextLayer } from "@deck.gl/layers";
4
+ import { createMultiTilesetDescriptor, RasterLayer, RasterTileset2D, resolveSecondaryTiles, selectSecondaryLevel, TileMatrixSetAdaptor, tilesetLevelsEqual, } from "@developmentseed/deck.gl-raster";
5
+ import { buildCompositeBandsProps, CompositeBands, } from "@developmentseed/deck.gl-raster/gpu-modules";
6
+ import { assembleTiles, defaultDecoderPool, generateTileMatrixSet, } from "@developmentseed/geotiff";
7
+ import { tileTransform } from "@developmentseed/morecantile";
8
+ import { epsgResolver as defaultEpsgResolver, makeClampedForwardTo3857, parseWkt, } from "@developmentseed/proj";
9
+ import proj4 from "proj4";
10
+ import { fetchGeoTIFF, getGeographicBounds } from "./geotiff/geotiff.js";
11
+ import { fromAffine } from "./geotiff-reprojection.js";
12
+ /** Size of deck.gl's common coordinate space in world units. */
13
+ const TILE_SIZE = 512;
14
+ /** The size of the globe in web mercator meters. */
15
+ const WEB_MERCATOR_METER_CIRCUMFERENCE = 40075016.686;
16
+ /**
17
+ * Scale factor for converting EPSG:3857 meters into deck.gl world units
18
+ * (512x512).
19
+ */
20
+ const WEB_MERCATOR_TO_WORLD_SCALE = TILE_SIZE / WEB_MERCATOR_METER_CIRCUMFERENCE;
21
+ /**
22
+ * Color palette for debug overlays.
23
+ *
24
+ * Index 0 is the primary tileset (red outline, white text).
25
+ * Indices 1+ cycle through distinct colors for secondary tilesets.
26
+ */
27
+ const DEBUG_COLORS = [
28
+ { outline: [255, 0, 0, 255], text: [255, 255, 255, 255] }, // primary: red outline, white text
29
+ { outline: [0, 255, 255, 255], text: [0, 255, 255, 255] }, // cyan
30
+ { outline: [255, 255, 0, 255], text: [255, 255, 0, 255] }, // yellow
31
+ { outline: [255, 0, 255, 255], text: [255, 0, 255, 255] }, // magenta
32
+ { outline: [0, 255, 128, 255], text: [0, 255, 128, 255] }, // lime
33
+ ];
34
+ const defaultProps = {
35
+ epsgResolver: { type: "accessor", value: defaultEpsgResolver },
36
+ maxError: { type: "number", value: 0.125 },
37
+ debug: { type: "boolean", value: false },
38
+ debugOpacity: { type: "number", value: 0.5 },
39
+ debugLevel: { type: "number", value: 1 },
40
+ };
41
+ /**
42
+ * A deck.gl {@link CompositeLayer} that opens multiple Cloud-Optimized GeoTIFFs
43
+ * (COGs) in parallel, builds a {@link TilesetDescriptor} for each, and groups
44
+ * them into a single {@link MultiTilesetDescriptor}.
45
+ *
46
+ * The finest-resolution source is automatically selected as the primary
47
+ * tileset, which drives the tile grid. Secondary sources are sampled at the
48
+ * closest matching resolution.
49
+ *
50
+ * @see {@link MultiCOGLayerProps} for accepted props.
51
+ * @see {@link createMultiTilesetDescriptor} for the grouping logic.
52
+ * @see {@link TileMatrixSetAdaptor} for the per-source tileset adapter.
53
+ */
54
+ export class MultiCOGLayer extends CompositeLayer {
55
+ static layerName = "MultiCOGLayer";
56
+ static defaultProps = defaultProps;
57
+ initializeState() {
58
+ this.setState({
59
+ sources: null,
60
+ multiDescriptor: null,
61
+ forwardTo4326: null,
62
+ inverseFrom4326: null,
63
+ forwardTo3857: null,
64
+ inverseFrom3857: null,
65
+ });
66
+ }
67
+ updateState({ changeFlags, props, oldProps, }) {
68
+ if (changeFlags.dataChanged || props.sources !== oldProps.sources) {
69
+ // Reset state so renderLayers() returns null while we re-open COGs.
70
+ // Without this, the TileLayer renders with new props but stale state,
71
+ // caching tiles with the wrong bands.
72
+ this.setState({
73
+ sources: null,
74
+ multiDescriptor: null,
75
+ });
76
+ this._parseAllSources();
77
+ }
78
+ }
79
+ /**
80
+ * Open all configured COG sources in parallel, compute shared projection
81
+ * functions, and build the {@link MultiTilesetDescriptor}.
82
+ *
83
+ * All sources are assumed to share the same CRS; the projection of the
84
+ * first source is used for the shared coordinate converters.
85
+ *
86
+ * @returns Resolves when all sources have been opened and state has been set.
87
+ */
88
+ async _parseAllSources() {
89
+ const { sources } = this.props;
90
+ const entries = Object.entries(sources);
91
+ // Open all COGs in parallel
92
+ const cogSources = await Promise.all(entries.map(async ([name, config]) => {
93
+ const geotiff = await fetchGeoTIFF(config.url);
94
+ const crs = geotiff.crs;
95
+ const sourceProjection = typeof crs === "number"
96
+ ? await this.props.epsgResolver(crs)
97
+ : parseWkt(crs);
98
+ const tms = generateTileMatrixSet(geotiff, sourceProjection);
99
+ return { name, geotiff, tms, sourceProjection };
100
+ }));
101
+ // Use the first source's projection for shared projection functions
102
+ // (all sources must share the same CRS)
103
+ const firstCogSource = cogSources[0];
104
+ const sourceProjection = firstCogSource.sourceProjection;
105
+ // @ts-expect-error - proj4 typings are incomplete and don't support
106
+ // wkt-parser input
107
+ const converter4326 = proj4(sourceProjection, "EPSG:4326");
108
+ const forwardTo4326 = (x, y) => converter4326.forward([x, y], false);
109
+ const inverseFrom4326 = (x, y) => converter4326.inverse([x, y], false);
110
+ // @ts-expect-error - proj4 typings are incomplete and don't support
111
+ // wkt-parser input
112
+ const converter3857 = proj4(sourceProjection, "EPSG:3857");
113
+ const forwardTo3857 = makeClampedForwardTo3857((x, y) => converter3857.forward([x, y], false), forwardTo4326);
114
+ const inverseFrom3857 = (x, y) => converter3857.inverse([x, y], false);
115
+ // Build TilesetDescriptors
116
+ const tilesetMap = new Map();
117
+ const sourceMap = new Map();
118
+ for (const cogSource of cogSources) {
119
+ const descriptor = new TileMatrixSetAdaptor(cogSource.tms, {
120
+ projectTo4326: forwardTo4326,
121
+ projectTo3857: forwardTo3857,
122
+ });
123
+ tilesetMap.set(cogSource.name, descriptor);
124
+ sourceMap.set(cogSource.name, {
125
+ geotiff: cogSource.geotiff,
126
+ tms: cogSource.tms,
127
+ });
128
+ }
129
+ const multiDescriptor = createMultiTilesetDescriptor(tilesetMap);
130
+ this.setState({
131
+ sources: sourceMap,
132
+ multiDescriptor,
133
+ forwardTo4326,
134
+ inverseFrom4326,
135
+ forwardTo3857,
136
+ inverseFrom3857,
137
+ });
138
+ if (this.props.onGeoTIFFLoad) {
139
+ const primaryKey = multiDescriptor.primaryKey;
140
+ const primaryGeotiff = sourceMap.get(primaryKey).geotiff;
141
+ const geographicBounds = getGeographicBounds(primaryGeotiff, converter4326);
142
+ const geotiffMap = new Map();
143
+ for (const [name, state] of sourceMap) {
144
+ geotiffMap.set(name, state.geotiff);
145
+ }
146
+ this.props.onGeoTIFFLoad(geotiffMap, { primaryKey, geographicBounds });
147
+ }
148
+ }
149
+ /**
150
+ * Fetch tile data for all configured sources at the given tile index.
151
+ *
152
+ * Primary-grid sources are fetched directly at (x, y, z). Secondary
153
+ * sources are resolved to covering tiles at the closest matching zoom
154
+ * level, fetched (potentially multiple tiles), stitched if necessary,
155
+ * and returned with the appropriate UV transform.
156
+ *
157
+ * @param tile - Tile load props from the TileLayer, containing index and signal.
158
+ * @returns Per-band textures, UV transforms, and reprojection functions.
159
+ */
160
+ async _getTileData(tile) {
161
+ const { signal } = tile;
162
+ const { x, y, z } = tile.index;
163
+ const { multiDescriptor, sources } = this.state;
164
+ const pool = this.props.pool ?? defaultDecoderPool();
165
+ const device = this.context.device;
166
+ // Combine abort signals if both are defined
167
+ const combinedSignal = signal && this.props.signal
168
+ ? AbortSignal.any([signal, this.props.signal])
169
+ : signal || this.props.signal;
170
+ // Compute reprojection transforms from the primary TMS
171
+ const primaryKey = multiDescriptor.primaryKey;
172
+ const primarySource = sources.get(primaryKey);
173
+ const primaryTms = primarySource.tms;
174
+ const tileMatrix = primaryTms.tileMatrices[z];
175
+ const tileAffine = tileTransform(tileMatrix, { col: x, row: y });
176
+ const { forwardTransform, inverseTransform } = fromAffine(tileAffine);
177
+ const primaryLevel = multiDescriptor.primary.levels[z];
178
+ // Collect fetch promises for all bands
179
+ const bandPromises = [];
180
+ for (const [name, sourceState] of sources) {
181
+ const descriptor = name === primaryKey
182
+ ? multiDescriptor.primary
183
+ : multiDescriptor.secondaries.get(name);
184
+ const isPrimary = name === primaryKey ||
185
+ tilesetLevelsEqual(descriptor.levels[z] ?? descriptor.levels[0], primaryLevel);
186
+ if (isPrimary) {
187
+ // Primary-grid source: fetch tile directly with identity UV transform
188
+ bandPromises.push(this._fetchPrimaryBand(name, sourceState, {
189
+ x,
190
+ y,
191
+ z,
192
+ pool,
193
+ signal: combinedSignal,
194
+ device,
195
+ }));
196
+ }
197
+ else {
198
+ // Secondary source: resolve covering tiles and fetch
199
+ bandPromises.push(this._fetchSecondaryBand(name, sourceState, {
200
+ descriptor,
201
+ primaryLevel,
202
+ primaryCol: x,
203
+ primaryRow: y,
204
+ primaryZ: z,
205
+ pool,
206
+ signal: combinedSignal,
207
+ device,
208
+ debug: this.props.debug ?? false,
209
+ }));
210
+ }
211
+ }
212
+ const bandEntries = await Promise.all(bandPromises);
213
+ const bands = new Map(bandEntries.map(([name, data]) => [name, data]));
214
+ // Collect debug info from secondary bands
215
+ let debugInfo;
216
+ if (this.props.debug) {
217
+ const debugBands = new Map();
218
+ for (const [name, , bandDebug] of bandEntries) {
219
+ if (bandDebug) {
220
+ debugBands.set(name, bandDebug);
221
+ }
222
+ }
223
+ debugInfo = { bands: debugBands };
224
+ }
225
+ const byteLength = [...bands.values()].reduce((sum, band) => sum + band.byteLength, 0);
226
+ console.log(`Tile (${x}, ${y}, ${z}): fetched bands [${[...bands.keys()].join(", ")}], total byte length: ${byteLength}`);
227
+ return {
228
+ bands,
229
+ forwardTransform,
230
+ inverseTransform,
231
+ width: primaryLevel.tileWidth,
232
+ height: primaryLevel.tileHeight,
233
+ byteLength,
234
+ debugInfo,
235
+ };
236
+ }
237
+ /**
238
+ * Fetch a single tile for a source that shares the primary tile grid.
239
+ *
240
+ * @returns A `[name, BandTileData, null]` tuple with identity UV transform
241
+ * and no debug info (primary bands don't need it).
242
+ */
243
+ async _fetchPrimaryBand(name, sourceState, opts) {
244
+ const { x, y, z, pool, signal, device } = opts;
245
+ const image = selectImage(sourceState.geotiff, z);
246
+ const tile = await image.fetchTile(x, y, {
247
+ boundless: true,
248
+ pool,
249
+ signal,
250
+ });
251
+ const texture = createBandTexture(device, tile.array);
252
+ const arr = tile.array;
253
+ const byteLength = arr.layout === "pixel-interleaved"
254
+ ? arr.data.byteLength
255
+ : arr.bands.reduce((sum, b) => sum + b.byteLength, 0);
256
+ return [
257
+ name,
258
+ {
259
+ texture,
260
+ uvTransform: [0, 0, 1, 1],
261
+ width: arr.width,
262
+ height: arr.height,
263
+ byteLength,
264
+ },
265
+ null,
266
+ ];
267
+ }
268
+ /**
269
+ * Fetch covering tiles for a secondary source and stitch them into a
270
+ * single texture using {@link assembleTiles}.
271
+ *
272
+ * @returns A `[name, BandTileData, BandDebugInfo | null]` tuple with the
273
+ * computed UV transform and optional debug metadata.
274
+ */
275
+ async _fetchSecondaryBand(name, sourceState, opts) {
276
+ const { descriptor, primaryLevel, primaryCol, primaryRow, primaryZ, pool, signal, device, } = opts;
277
+ // Select the best secondary level
278
+ const primaryMpp = this.state.multiDescriptor.primary.levels[primaryZ].metersPerPixel;
279
+ const secondaryLevel = selectSecondaryLevel(descriptor.levels, primaryMpp);
280
+ const secondaryZ = descriptor.levels.indexOf(secondaryLevel);
281
+ // Resolve covering tile indices and UV transform
282
+ const resolution = resolveSecondaryTiles(primaryLevel, primaryCol, primaryRow, secondaryLevel, secondaryZ);
283
+ // Collect debug info if requested
284
+ let debugInfo = null;
285
+ if (opts.debug) {
286
+ const secondaryTileCorners = resolution.tileIndices.map((idx) => secondaryLevel.projectedTileCorners(idx.x, idx.y));
287
+ debugInfo = {
288
+ secondaryTileCorners,
289
+ secondaryZ,
290
+ uvTransform: resolution.uvTransform,
291
+ stitchedWidth: resolution.stitchedWidth,
292
+ stitchedHeight: resolution.stitchedHeight,
293
+ tileCount: resolution.tileIndices.length,
294
+ metersPerPixel: secondaryLevel.metersPerPixel,
295
+ };
296
+ }
297
+ // Fetch all covering tiles via fetchTiles
298
+ const image = selectImage(sourceState.geotiff, secondaryZ);
299
+ const xy = resolution.tileIndices.map((idx) => [
300
+ idx.x,
301
+ idx.y,
302
+ ]);
303
+ const tiles = await image.fetchTiles(xy, {
304
+ boundless: true,
305
+ pool,
306
+ signal,
307
+ });
308
+ // Assemble into a single RasterArray (handles stitching + typed array preservation)
309
+ const assembled = assembleTiles(tiles, {
310
+ width: resolution.stitchedWidth,
311
+ height: resolution.stitchedHeight,
312
+ tileWidth: secondaryLevel.tileWidth,
313
+ tileHeight: secondaryLevel.tileHeight,
314
+ minCol: resolution.minCol,
315
+ minRow: resolution.minRow,
316
+ });
317
+ const texture = createBandTexture(device, assembled);
318
+ const assembledByteLength = assembled.layout === "pixel-interleaved"
319
+ ? assembled.data.byteLength
320
+ : assembled.bands.reduce((sum, b) => sum + b.byteLength, 0);
321
+ return [
322
+ name,
323
+ {
324
+ texture,
325
+ uvTransform: resolution.uvTransform,
326
+ width: assembled.width,
327
+ height: assembled.height,
328
+ byteLength: assembledByteLength,
329
+ },
330
+ debugInfo,
331
+ ];
332
+ }
333
+ /**
334
+ * Create sub-layers for a single loaded tile.
335
+ *
336
+ * Builds a {@link RasterLayer} with reprojection functions and a render
337
+ * pipeline that starts with a {@link CompositeBands} module binding all
338
+ * band textures, followed by any user-provided pipeline modules.
339
+ */
340
+ _renderSubLayers(props, forwardTo4326, inverseFrom4326, forwardTo3857, inverseFrom3857) {
341
+ const { maxError, debug, debugOpacity } = this.props;
342
+ if (!props.data) {
343
+ return null;
344
+ }
345
+ const { bands, forwardTransform, inverseTransform, width, height } = props.data;
346
+ // Build the composite bands mapping — default to first source for R if
347
+ // no composite mapping is provided
348
+ const composite = this.props.composite ?? {
349
+ r: [...bands.keys()][0],
350
+ };
351
+ // Skip rendering if cached tile data doesn't have the required bands
352
+ // (happens when switching presets — old tiles will be re-fetched)
353
+ const requiredBands = [
354
+ composite.r,
355
+ composite.g,
356
+ composite.b,
357
+ composite.a,
358
+ ].filter((n) => n != null);
359
+ if (requiredBands.some((name) => !bands.has(name))) {
360
+ return null;
361
+ }
362
+ // Map named bands to fixed slot indices and build module props
363
+ const compositeBandsProps = buildCompositeBandsProps(composite, bands);
364
+ const renderPipeline = [
365
+ {
366
+ module: CompositeBands,
367
+ props: compositeBandsProps,
368
+ },
369
+ ...(this.props.renderPipeline ?? []),
370
+ ];
371
+ // Determine projection mode (globe vs web mercator)
372
+ const isGlobe = this.context.viewport.resolution !== undefined;
373
+ let reprojectionFns;
374
+ let deckProjectionProps;
375
+ if (isGlobe) {
376
+ reprojectionFns = {
377
+ forwardTransform,
378
+ inverseTransform,
379
+ forwardReproject: forwardTo4326,
380
+ inverseReproject: inverseFrom4326,
381
+ };
382
+ deckProjectionProps = {};
383
+ }
384
+ else {
385
+ reprojectionFns = {
386
+ forwardTransform,
387
+ inverseTransform,
388
+ forwardReproject: forwardTo3857,
389
+ inverseReproject: inverseFrom3857,
390
+ };
391
+ deckProjectionProps = {
392
+ coordinateSystem: COORDINATE_SYSTEM.CARTESIAN,
393
+ coordinateOrigin: [TILE_SIZE / 2, TILE_SIZE / 2, 0],
394
+ // biome-ignore format: array
395
+ modelMatrix: [
396
+ WEB_MERCATOR_TO_WORLD_SCALE, 0, 0, 0,
397
+ 0, WEB_MERCATOR_TO_WORLD_SCALE, 0, 0,
398
+ 0, 0, 1, 0,
399
+ 0, 0, 0, 1
400
+ ],
401
+ };
402
+ }
403
+ const rasterLayer = new RasterLayer(this.getSubLayerProps({
404
+ id: `${props.id}-raster`,
405
+ width,
406
+ height,
407
+ renderPipeline,
408
+ maxError,
409
+ reprojectionFns,
410
+ debug,
411
+ debugOpacity,
412
+ ...deckProjectionProps,
413
+ }));
414
+ const sublayers = [rasterLayer];
415
+ if (debug && props.data) {
416
+ sublayers.push(...this._renderDebugLayers(props.id, props.tile, props.data, forwardTo4326));
417
+ }
418
+ return sublayers;
419
+ }
420
+ /**
421
+ * Render debug overlay layers for a single tile: colored outlines for
422
+ * primary and secondary tile boundaries, and tiered text labels.
423
+ *
424
+ * @param tileId - Base id for sub-layer naming.
425
+ * @param tile - The tile header with index info.
426
+ * @param data - The fetched multi-tile result containing debug info.
427
+ * @param forwardTo4326 - Projection function for converting CRS corners to WGS84.
428
+ * @returns Array of PathLayer and TextLayer sub-layers.
429
+ */
430
+ _renderDebugLayers(tileId, tile, data, forwardTo4326) {
431
+ const layers = [];
432
+ const debugLevel = this.props.debugLevel ?? 1;
433
+ const { multiDescriptor } = this.state;
434
+ if (!multiDescriptor)
435
+ return layers;
436
+ const { x, y, z } = tile.index;
437
+ const primaryLevel = multiDescriptor.primary.levels[z];
438
+ if (!primaryLevel)
439
+ return layers;
440
+ // --- Primary tile outline and label ---
441
+ const primaryCrsCorners = primaryLevel.projectedTileCorners(x, y);
442
+ const { path: primaryPath, center: primaryCenter } = cornersToWgs84Path(primaryCrsCorners, forwardTo4326);
443
+ const primaryColor = DEBUG_COLORS[0];
444
+ layers.push(new PathLayer({
445
+ id: `${tileId}-debug-primary-outline`,
446
+ data: [primaryPath],
447
+ getPath: (d) => d,
448
+ getColor: primaryColor.outline,
449
+ getWidth: 2,
450
+ widthUnits: "pixels",
451
+ pickable: false,
452
+ }));
453
+ // Build primary label text
454
+ let primaryText = `x=${x} y=${y} z=${z}`;
455
+ if (debugLevel >= 2) {
456
+ primaryText += ` ${data.width}x${data.height}`;
457
+ }
458
+ if (debugLevel >= 3) {
459
+ primaryText += ` ${primaryLevel.metersPerPixel.toFixed(1)}m/px`;
460
+ }
461
+ // Count total label lines for vertical stacking
462
+ const secondaryNames = data.debugInfo
463
+ ? [...data.debugInfo.bands.keys()]
464
+ : [];
465
+ const totalLines = 1 + secondaryNames.length;
466
+ const lineSpacing = 18; // pixels
467
+ const topOffset = ((totalLines - 1) * lineSpacing) / 2;
468
+ layers.push(new TextLayer({
469
+ id: `${tileId}-debug-primary-label`,
470
+ data: [
471
+ {
472
+ position: primaryCenter,
473
+ text: primaryText,
474
+ },
475
+ ],
476
+ getColor: primaryColor.text,
477
+ getSize: 14,
478
+ getPixelOffset: [0, -topOffset],
479
+ sizeUnits: "pixels",
480
+ outlineWidth: 3,
481
+ outlineColor: [0, 0, 0, 255],
482
+ fontSettings: { sdf: true },
483
+ }));
484
+ // --- Secondary tile outlines and labels ---
485
+ if (!data.debugInfo)
486
+ return layers;
487
+ let secondaryIdx = 0;
488
+ for (const [name, info] of data.debugInfo.bands) {
489
+ const colorEntry = DEBUG_COLORS[1 + (secondaryIdx % (DEBUG_COLORS.length - 1))];
490
+ // Draw outline for each secondary tile
491
+ for (let i = 0; i < info.secondaryTileCorners.length; i++) {
492
+ const { path: secondaryPath } = cornersToWgs84Path(info.secondaryTileCorners[i], forwardTo4326);
493
+ layers.push(new PathLayer({
494
+ id: `${tileId}-debug-${name}-outline-${i}`,
495
+ data: [secondaryPath],
496
+ getPath: (d) => d,
497
+ getColor: colorEntry.outline,
498
+ getWidth: 2,
499
+ widthUnits: "pixels",
500
+ pickable: false,
501
+ }));
502
+ }
503
+ // Build secondary label text
504
+ const mpp = info.metersPerPixel.toFixed(1);
505
+ let labelText = `${name}: ${mpp}m z=${info.secondaryZ}`;
506
+ if (debugLevel >= 2) {
507
+ const uv = info.uvTransform;
508
+ labelText += ` uv=[${uv.map((v) => v.toFixed(2)).join(",")}] ${info.tileCount} tiles`;
509
+ }
510
+ if (debugLevel >= 3) {
511
+ labelText += ` stitch=${info.stitchedWidth}x${info.stitchedHeight}`;
512
+ }
513
+ const lineOffset = -topOffset + (1 + secondaryIdx) * lineSpacing;
514
+ layers.push(new TextLayer({
515
+ id: `${tileId}-debug-${name}-label`,
516
+ data: [
517
+ {
518
+ position: primaryCenter,
519
+ text: labelText,
520
+ },
521
+ ],
522
+ getColor: colorEntry.text,
523
+ getSize: 12,
524
+ getPixelOffset: [0, lineOffset],
525
+ sizeUnits: "pixels",
526
+ outlineWidth: 2,
527
+ outlineColor: [0, 0, 0, 255],
528
+ fontSettings: { sdf: true },
529
+ }));
530
+ secondaryIdx++;
531
+ }
532
+ return layers;
533
+ }
534
+ /**
535
+ * Build the tile layer that drives tile traversal and rendering.
536
+ *
537
+ * Creates a {@link RasterTileset2D} factory from the primary tileset,
538
+ * then returns a {@link TileLayer} wired up with tile fetching and
539
+ * sub-layer rendering.
540
+ */
541
+ renderTileLayer(multiDescriptor, forwardTo4326, inverseFrom4326, forwardTo3857, inverseFrom3857) {
542
+ const { primary } = multiDescriptor;
543
+ // Create a factory class that wraps RasterTileset2D with the primary descriptor
544
+ class PrimaryTilesetFactory extends RasterTileset2D {
545
+ constructor(opts) {
546
+ super(opts, primary, {
547
+ projectTo4326: forwardTo4326,
548
+ });
549
+ }
550
+ }
551
+ const { maxRequests, maxCacheSize, maxCacheByteSize, debounceTime, refinementStrategy, } = this.props;
552
+ // Stringify sources to detect when the set of COG URLs changes.
553
+ // This triggers TileLayer to invalidate its cache and re-fetch.
554
+ const sourceKeys = Object.keys(this.props.sources).sort().join(",");
555
+ const sourceUrls = Object.values(this.props.sources)
556
+ .map((s) => String(s.url))
557
+ .sort()
558
+ .join(",");
559
+ return new TileLayer({
560
+ id: `multi-cog-tile-layer-${this.id}-${sourceUrls}`,
561
+ TilesetClass: PrimaryTilesetFactory,
562
+ getTileData: async (tile) => this._getTileData(tile),
563
+ renderSubLayers: (props) => this._renderSubLayers(props, forwardTo4326, inverseFrom4326, forwardTo3857, inverseFrom3857),
564
+ updateTriggers: {
565
+ getTileData: [sourceKeys, sourceUrls],
566
+ },
567
+ debounceTime,
568
+ maxCacheByteSize,
569
+ maxCacheSize,
570
+ maxRequests,
571
+ refinementStrategy,
572
+ });
573
+ }
574
+ renderLayers() {
575
+ const { multiDescriptor, forwardTo4326, inverseFrom4326, forwardTo3857, inverseFrom3857, } = this.state;
576
+ if (!multiDescriptor ||
577
+ !forwardTo4326 ||
578
+ !inverseFrom4326 ||
579
+ !forwardTo3857 ||
580
+ !inverseFrom3857) {
581
+ return null;
582
+ }
583
+ return this.renderTileLayer(multiDescriptor, forwardTo4326, inverseFrom4326, forwardTo3857, inverseFrom3857);
584
+ }
585
+ }
586
+ /**
587
+ * Select the correct GeoTIFF image (full-res or overview) for a zoom level.
588
+ *
589
+ * z=0 is the coarsest overview, z=max is full resolution.
590
+ */
591
+ function selectImage(geotiff, z) {
592
+ const images = [geotiff, ...geotiff.overviews];
593
+ return images[images.length - 1 - z];
594
+ }
595
+ /**
596
+ * Create a GPU texture from a {@link RasterArray}.
597
+ *
598
+ * Infers the texture format from the typed array type. Currently supports
599
+ * single-band `Uint8Array` (`r8unorm`) and `Uint16Array` (`r16unorm`).
600
+ *
601
+ * TODO: use `inferTextureFormat` from `texture.ts` for full format support.
602
+ */
603
+ function createBandTexture(device, array) {
604
+ if (array.layout !== "pixel-interleaved") {
605
+ throw new Error("Band-separate layout not yet supported in MultiCOGLayer");
606
+ }
607
+ const { data, width, height } = array;
608
+ let format;
609
+ if (data instanceof Uint8Array || data instanceof Uint8ClampedArray) {
610
+ format = "r8unorm";
611
+ }
612
+ else if (data instanceof Uint16Array) {
613
+ format = "r16unorm";
614
+ }
615
+ else {
616
+ throw new Error(`Unsupported typed array type: ${data.constructor.name}. ` +
617
+ "Currently only Uint8Array and Uint16Array are supported.");
618
+ }
619
+ return device.createTexture({
620
+ data,
621
+ format,
622
+ width,
623
+ height,
624
+ sampler: { minFilter: "linear", magFilter: "linear" },
625
+ });
626
+ }
627
+ /**
628
+ * Project CRS tile corners to WGS84 and return a closed path suitable for
629
+ * PathLayer, plus the center point for label placement.
630
+ *
631
+ * @param corners - Tile corners in the source CRS.
632
+ * @param projectTo4326 - Projection function from source CRS to WGS84.
633
+ * @returns A closed `[topLeft, topRight, bottomRight, bottomLeft, topLeft]`
634
+ * path and the geographic center.
635
+ */
636
+ function cornersToWgs84Path(corners, projectTo4326) {
637
+ const topLeft = projectTo4326(corners.topLeft[0], corners.topLeft[1]);
638
+ const topRight = projectTo4326(corners.topRight[0], corners.topRight[1]);
639
+ const bottomRight = projectTo4326(corners.bottomRight[0], corners.bottomRight[1]);
640
+ const bottomLeft = projectTo4326(corners.bottomLeft[0], corners.bottomLeft[1]);
641
+ return {
642
+ path: [topLeft, topRight, bottomRight, bottomLeft, topLeft],
643
+ center: [
644
+ (topLeft[0] + bottomRight[0]) / 2,
645
+ (topLeft[1] + bottomRight[1]) / 2,
646
+ ],
647
+ };
648
+ }
649
+ //# sourceMappingURL=multi-cog-layer.js.map