layer/geotiff/GeoTIFFReader.tstypescript
import { fromArrayBuffer, fromBlob, fromUrl, GeoTIFF, GeoTIFFImage, Pool } from "geotiff";
import type { ReadRastersOptions, TypedArray } from "geotiff";
import { Extent } from "../../Extent";
import { LonLat } from "../../LonLat";
import * as mercator from "../../mercator";
import { getFastNoDataChecker, getRasterMinMax, parseNoDataValue } from "./ColorScale";
import { getProjectionHelper, type IProjectionHelper } from "./utm";
import { Proj } from "../../proj/Proj";
import { EPSG3857 } from "../../proj/EPSG3857";
import { EPSG4326 } from "../../proj/EPSG4326";
import type { DecodedTileData, IGeoTIFFLayerParams, IGeoTIFFMetadata, ProjFunc } from "./types";

/**
 * Sampling grid of a tile, source pixel positions are interpolated between its nodes.
 */
interface TileGrid {
    gridU: Float64Array;
    gridV: Float64Array;
    gridSize: number;
    uMin: number;
    uMax: number;
    vMin: number;
    vMax: number;
}

const GRID_MIN_SIZE = 4;

/**
 * Finest grid, reached only by tiles that span a large part of the globe.
 */
const GRID_MAX_SIZE = 64;

/**
 * Allowed interpolation error between the grid nodes, in tile pixels.
 */
const GRID_MAX_ERROR = 0.25;

/**
 * Size of a cached byte range of a remote file.
 */
const BLOCK_SIZE = 65536;

/**
 * Number of cached byte ranges, 512 blocks of 64 KB take about 32 MB.
 */
const BLOCK_CACHE_SIZE = 512;

const OVERVIEW_RESOLUTION_TOLERANCE = 1.4;

export class GeoTIFFReader {
    public source: GeoTIFF | null = null;
    public images: GeoTIFFImage[] = [];
    public workerPool: Pool | null = null;
    public metadata: IGeoTIFFMetadata | null = null;
    public extentWgs84: Extent = new Extent();
    public nativeBBox: [number, number, number, number] = [0, 0, 0, 0];
    public crsCode: number = 4326;
    public projFunc?: ProjFunc;
    public reverseY: boolean = false;
    public requestLevels: number[] = [];
    public isReady: boolean = false;

    private _ownsWorkerPool: boolean = false;
    private _projHelper: IProjectionHelper | null = null;

    constructor(public options: IGeoTIFFLayerParams = {}) {
        this.projFunc = options.projFunc;
    }

    /**
     * Initializes and parses the GeoTIFF source and its overview pyramid.
     */
    public async init(src: string | Blob | File | ArrayBuffer): Promise<IGeoTIFFMetadata> {
        let source: GeoTIFF;

        if (typeof src === "string") {
            source = await fromUrl(src, {
                blockSize: BLOCK_SIZE,
                cacheSize: BLOCK_CACHE_SIZE,
                ...this.options.requestOptions
            });
        } else if (
            src instanceof ArrayBuffer ||
            (typeof ArrayBuffer !== "undefined" && Object.prototype.toString.call(src) === "[object ArrayBuffer]")
        ) {
            source = await fromArrayBuffer(src as ArrayBuffer);
        } else if (ArrayBuffer.isView(src)) {
            const view = src as ArrayBufferView;
            const buf = view.buffer.slice(view.byteOffset, view.byteOffset + view.byteLength);
            source = await fromArrayBuffer(buf as ArrayBuffer);
        } else if (
            (typeof Blob !== "undefined" && src instanceof Blob) ||
            (typeof File !== "undefined" && (src as any) instanceof File) ||
            (src && typeof (src as any).slice === "function" && typeof (src as any).size === "number")
        ) {
            source = await fromBlob(src as any);
        } else {
            throw new Error("Invalid GeoTIFF source provided.");
        }

        this.source = source;

        // Initialize Web Worker Pool for decoding if supported
        if (this.options.geotiffWorkerPool) {
            this.workerPool = this.options.geotiffWorkerPool;
            this._ownsWorkerPool = false;
        } else {
            const poolSize =
                this.options.workerPoolSize ??
                (typeof navigator !== "undefined" ? navigator.hardwareConcurrency || 2 : 2);
            const isWorkerSupported =
                typeof Worker !== "undefined" &&
                typeof Worker.prototype !== "undefined" &&
                typeof Worker.prototype.addEventListener === "function";

            if (poolSize > 0 && isWorkerSupported) {
                try {
                    this.workerPool = new Pool(poolSize);
                    this._ownsWorkerPool = true;
                } catch {
                    this.workerPool = null;
                    this._ownsWorkerPool = false;
                }
            } else {
                this.workerPool = null;
                this._ownsWorkerPool = false;
            }
        }

        const imageCount = await source.getImageCount();
        this.images = [];
        for (let i = 0; i < imageCount; i++) {
            const img = await source.getImage(i);

            // Mask images are skipped, only the full resolution image and its overviews are used
            const subfileType = Number(img.getFileDirectory().getValue("NewSubfileType") ?? 0);

            if (i === 0 || (subfileType & 1) === 1) {
                this.images.push(img);
            }
        }

        const baseImage = this.images[0];
        this.reverseY = this._checkIfReversed(baseImage);

        // Bounding box in native coordinates [minX, minY, maxX, maxY]
        this.nativeBBox = baseImage.getBoundingBox() as [number, number, number, number];
        const [minX, minY, maxX, maxY] = this.nativeBBox;

        // Determine CRS
        const geoKeys = baseImage.getGeoKeys() || {};
        let crsCode = +(geoKeys.ProjectedCSTypeGeoKey || geoKeys.GeographicTypeGeoKey || 0);

        if (crsCode === 32767 || crsCode === 65535 || !crsCode) {
            if (minX >= -180.5 && maxX <= 180.5 && minY >= -90.5 && maxY <= 90.5) {
                crsCode = 4326;
            } else {
                crsCode = 3857;
            }
        }
        this.crsCode = crsCode;
        this._projHelper = getProjectionHelper(crsCode, this.projFunc);

        // Calculate WGS84 Extent
        if (crsCode === 4326) {
            this.extentWgs84 = new Extent(new LonLat(minX, minY), new LonLat(maxX, maxY));
        } else if (crsCode === 3857 || crsCode === 900913) {
            const swLon = mercator.inverse_lon(minX);
            const swLat = mercator.inverse_lat(minY);
            const neLon = mercator.inverse_lon(maxX);
            const neLat = mercator.inverse_lat(maxY);
            this.extentWgs84 = new Extent(new LonLat(swLon, swLat), new LonLat(neLon, neLat));
        } else if (this._projHelper) {
            const sw = this._projHelper.unproject([minX, minY]);
            const se = this._projHelper.unproject([maxX, minY]);
            const nw = this._projHelper.unproject([minX, maxY]);
            const ne = this._projHelper.unproject([maxX, maxY]);
            const lons = [sw[0], se[0], nw[0], ne[0]];
            const lats = [sw[1], se[1], nw[1], ne[1]];
            this.extentWgs84 = new Extent(
                new LonLat(Math.min(...lons), Math.min(...lats)),
                new LonLat(Math.max(...lons), Math.max(...lats))
            );
        } else {
            console.warn(`[GeoTIFFReader] Unrecognized CRS: EPSG:${crsCode}. Assuming bounds are lon/lat.`);
            this.extentWgs84 = new Extent(new LonLat(minX, minY), new LonLat(maxX, maxY));
        }

        const samplesPerPixel = baseImage.getSamplesPerPixel();
        const gdalNoData = parseNoDataValue(baseImage.getGDALNoData());
        let userNoData: number | null | undefined = undefined;
        if (this.options.renderOptions?.nodata !== undefined) {
            userNoData = parseNoDataValue(this.options.renderOptions.nodata);
        } else if (this.options.renderOptions?.noData !== undefined) {
            userNoData = parseNoDataValue(this.options.renderOptions.noData);
        } else if (this.options.nodata !== undefined) {
            userNoData = parseNoDataValue(this.options.nodata);
        } else if (this.options.noData !== undefined) {
            userNoData = parseNoDataValue(this.options.noData);
        }
        const noData = userNoData !== undefined ? userNoData : (gdalNoData ?? null);

        // Sample band min/max statistics from metadata or thumbnail overview
        const bands: Record<number, { min: number; max: number }> = {};
        const previewImage = this.images[this.images.length - 1];

        if (samplesPerPixel >= 3) {
            for (let i = 0; i < samplesPerPixel; i++) {
                const bandNum = i + 1;
                const meta = await baseImage.getGDALMetadata(i);
                if (meta?.STATISTICS_MINIMUM && meta?.STATISTICS_MAXIMUM) {
                    bands[bandNum] = {
                        min: parseFloat(String(meta.STATISTICS_MINIMUM)),
                        max: parseFloat(String(meta.STATISTICS_MAXIMUM))
                    };
                } else {
                    bands[bandNum] = { min: 0, max: 255 };
                }
            }
        } else {
            let needsSampling = false;
            for (let i = 0; i < samplesPerPixel; i++) {
                const bandNum = i + 1;
                const meta = await baseImage.getGDALMetadata(i);
                if (meta?.STATISTICS_MINIMUM && meta?.STATISTICS_MAXIMUM) {
                    bands[bandNum] = {
                        min: parseFloat(String(meta.STATISTICS_MINIMUM)),
                        max: parseFloat(String(meta.STATISTICS_MAXIMUM))
                    };
                } else {
                    needsSampling = true;
                }
            }

            if (needsSampling && previewImage) {
                try {
                    const sampleRasters = await previewImage.readRasters({
                        interleave: false,
                        pool: this.workerPool || undefined
                    });
                    for (let i = 0; i < samplesPerPixel; i++) {
                        const bandNum = i + 1;
                        if (!bands[bandNum]) {
                            bands[bandNum] = getRasterMinMax(sampleRasters[i], noData);
                        }
                    }
                } catch {
                    for (let i = 0; i < samplesPerPixel; i++) {
                        const bandNum = i + 1;
                        if (!bands[bandNum]) {
                            bands[bandNum] = { min: 0, max: 255 };
                        }
                    }
                }
            }
        }

        this.metadata = {
            bbox: this.nativeBBox,
            crsCode: this.crsCode,
            width: baseImage.getWidth(),
            height: baseImage.getHeight(),
            samplesPerPixel,
            noData,
            isTiled: baseImage.isTiled,
            overviewCount: this.images.length,
            bands
        };

        this.isReady = true;
        return this.metadata;
    }

    /**
     * Reads raster data for a tile and warps it into the tile grid.
     *
     * Source pixel coordinates are calculated in the nodes of a coarse grid and interpolated in between,
     * so rotated grids (UTM and other projected systems) land on the tile without seams.
     *
     * @param tileExtentLonLat - Tile extent in degrees.
     * @param _zoomLevel - Tile zoom level, kept for API compatibility.
     * @param tileSize - Output tile size in pixels.
     * @param readSamples - Band indexes to read.
     * @param segmentProj - Projection of the segment the tile belongs to, EPSG:3857 by default.
     */
    public async readTileRasters(
        tileExtentLonLat: Extent,
        _zoomLevel: number,
        tileSize: number = 256,
        readSamples?: number[],
        segmentProj: Proj = EPSG3857
    ): Promise<DecodedTileData | null> {
        if (!this.isReady || !this.source || this.images.length === 0) {
            return null;
        }

        // 1. Check spatial intersection with WGS84 extent
        if (!this.extentWgs84.overlaps(tileExtentLonLat)) {
            return null;
        }

        const [nativeWest, nativeSouth, nativeEast, nativeNorth] = this.nativeBBox;
        const nativeWidth = nativeEast - nativeWest;
        const nativeHeight = nativeNorth - nativeSouth;
        if (nativeWidth <= 0 || nativeHeight <= 0) return null;

        // 2. Sample the tile with a coarse grid: every node gets its position inside the raster,
        // normalized to 0..1, where u grows to the east and v grows down the raster rows.
        const grid = this._createTileGrid(tileExtentLonLat, tileSize, segmentProj);
        if (!grid) return null;

        const { uMin, uMax, vMin, vMax } = grid;

        if (uMax < 0 || uMin > 1 || vMax < 0 || vMin > 1) {
            return null;
        }

        // 3. Select the overview whose resolution matches the tile resolution
        const image = this.images[this._selectOverviewIndex(grid, tileSize)];

        if (!image) return null;

        const imgWidth = image.getWidth();
        const imgHeight = image.getHeight();

        // 4. Source window covers the whole projected tile boundary, with a one pixel margin.
        // A tile may be larger than the raster itself, so the window is clamped to the image.
        const winX0 = Math.max(0, Math.floor(Math.max(0, uMin) * imgWidth) - 1);
        const winY0 = Math.max(0, Math.floor(Math.max(0, vMin) * imgHeight) - 1);
        const winX1 = Math.min(imgWidth, Math.ceil(Math.min(1, uMax) * imgWidth) + 1);
        const winY1 = Math.min(imgHeight, Math.ceil(Math.min(1, vMax) * imgHeight) + 1);

        if (winX1 <= winX0 || winY1 <= winY0) {
            return null;
        }

        const options: ReadRastersOptions & { interleave: false } = {
            window: [winX0, winY0, winX1, winY1],
            pool: this.workerPool || undefined,
            fillValue: this._getFillValue(),
            interleave: false
        };

        if (readSamples && readSamples.length > 0) {
            options.samples = readSamples;
        }

        try {
            const rasters = await image.readRasters(options);

            return {
                rasters: this._warpRasters(rasters, grid, imgWidth, imgHeight, [winX0, winY0, winX1, winY1], tileSize),
                width: tileSize,
                height: tileSize,
                dstX: 0,
                dstY: 0,
                tileSize,
                window: [winX0, winY0, winX1, winY1],
                isRGB: false
            };
        } catch (err) {
            console.error("[GeoTIFFReader] Error reading rasters:", err);
            return null;
        }
    }

    /**
     * Projects a lon/lat point into the raster coordinates, normalized to 0..1.
     */
    protected _projectToRaster(lon: number, lat: number): [number, number] {
        const [nativeWest, nativeSouth, nativeEast, nativeNorth] = this.nativeBBox;

        let x: number, y: number;

        if (this.crsCode === 4326) {
            x = lon;
            y = lat;
        } else if (this.crsCode === 3857 || this.crsCode === 900913) {
            x = mercator.forward_lon(lon);
            y = mercator.forward_lat(lat);
        } else if (this._projHelper) {
            const p = this._projHelper.project([lon, lat]);
            x = p[0];
            y = p[1];
        } else {
            x = lon;
            y = lat;
        }

        const u = (x - nativeWest) / (nativeEast - nativeWest);
        const v = this.reverseY
            ? (y - nativeSouth) / (nativeNorth - nativeSouth)
            : (nativeNorth - y) / (nativeNorth - nativeSouth);

        return [u, v];
    }

    protected _mercatorLatFunc(north: number, south: number): (t: number) => number {
        const top = mercator.forward_lat(north);
        const bottom = mercator.forward_lat(south);
        return (t: number) => mercator.inverse_lat(top + (bottom - top) * t);
    }

    protected _lonLatLatFunc(north: number, south: number): (t: number) => number {
        return (t: number) => north + (south - north) * t;
    }

    protected _createLatFunc(segmentProj: Proj, north: number, south: number): (t: number) => number {
        return segmentProj.equal(EPSG4326) ? this._lonLatLatFunc(north, south) : this._mercatorLatFunc(north, south);
    }

    private _buildGrid(west: number, east: number, latFunc: (t: number) => number, gridSize: number): TileGrid {
        const side = gridSize + 1;
        const gridU = new Float64Array(side * side);
        const gridV = new Float64Array(side * side);

        let uMin = Infinity,
            uMax = -Infinity,
            vMin = Infinity,
            vMax = -Infinity;

        for (let j = 0; j < side; j++) {
            const lat = latFunc(j / gridSize);

            for (let i = 0; i < side; i++) {
                const lon = west + (east - west) * (i / gridSize);
                const [u, v] = this._projectToRaster(lon, lat);
                const n = j * side + i;

                gridU[n] = u;
                gridV[n] = v;

                if (u < uMin) uMin = u;
                if (u > uMax) uMax = u;
                if (v < vMin) vMin = v;
                if (v > vMax) vMax = v;
            }
        }

        return { gridU, gridV, gridSize, uMin, uMax, vMin, vMax };
    }

    /**
     * Interpolation error of the grid, measured in tile pixels.
     * In every cell the interpolated center is compared with the exact projection of that center.
     */
    private _gridError(
        grid: TileGrid,
        west: number,
        east: number,
        latFunc: (t: number) => number,
        tileSize: number
    ): number {
        const baseWidth = this.images[0].getWidth();
        const baseHeight = this.images[0].getHeight();
        const { gridU, gridV, gridSize } = grid;
        const side = gridSize + 1;
        const cellSize = tileSize / gridSize;

        let maxError = 0;

        for (let j = 0; j < gridSize; j++) {
            const lat = latFunc((j + 0.5) / gridSize);

            for (let i = 0; i < gridSize; i++) {
                const lon = west + (east - west) * ((i + 0.5) / gridSize);
                const [u, v] = this._projectToRaster(lon, lat);

                const n00 = j * side + i;
                const n10 = n00 + 1;
                const n01 = n00 + side;
                const n11 = n01 + 1;

                const du = ((gridU[n00] + gridU[n10] + gridU[n01] + gridU[n11]) / 4 - u) * baseWidth;
                const dv = ((gridV[n00] + gridV[n10] + gridV[n01] + gridV[n11]) / 4 - v) * baseHeight;

                const dx = Math.hypot((gridU[n10] - gridU[n00]) * baseWidth, (gridV[n10] - gridV[n00]) * baseHeight);
                const dy = Math.hypot((gridU[n01] - gridU[n00]) * baseWidth, (gridV[n01] - gridV[n00]) * baseHeight);

                const density = Math.min(dx, dy) / cellSize;

                if (density > 0) {
                    maxError = Math.max(maxError, Math.hypot(du, dv) / density);
                }
            }
        }

        return maxError;
    }

    /**
     * Builds the sampling grid, refining it until the interpolation error becomes small enough.
     * Almost affine cases, such as a UTM raster on a zoomed in tile, stay on the coarsest grid.
     */
    private _createTileGrid(tileExtentLonLat: Extent, tileSize: number, segmentProj: Proj): TileGrid | null {
        const west = tileExtentLonLat.southWest.lon;
        const east = tileExtentLonLat.northEast.lon;
        const south = tileExtentLonLat.southWest.lat;
        const north = tileExtentLonLat.northEast.lat;

        if (east <= west || north <= south) return null;

        const latFunc = this._createLatFunc(segmentProj, north, south);

        let grid = this._buildGrid(west, east, latFunc, GRID_MIN_SIZE);

        while (grid.gridSize < GRID_MAX_SIZE && this._gridError(grid, west, east, latFunc, tileSize) > GRID_MAX_ERROR) {
            grid = this._buildGrid(west, east, latFunc, grid.gridSize * 2);
        }

        if (!Number.isFinite(grid.uMin) || !Number.isFinite(grid.vMin)) {
            return null;
        }

        return grid;
    }

    private _selectOverviewIndex(grid: TileGrid, tileSize: number): number {
        if (this.images.length <= 1) {
            return 0;
        }

        const { gridU, gridV, gridSize } = grid;
        const baseWidth = this.images[0].getWidth();
        const baseHeight = this.images[0].getHeight();
        const side = gridSize + 1;
        const cellSize = tileSize / gridSize;

        // How many full resolution source pixels fall into one tile pixel
        let density = 0;
        for (let j = 0; j < side; j++) {
            for (let i = 0; i < gridSize; i++) {
                const n = j * side + i;
                const du = Math.abs(gridU[n + 1] - gridU[n]) * baseWidth;
                const dv = Math.abs(gridV[n + 1] - gridV[n]) * baseHeight;
                density = Math.max(density, Math.hypot(du, dv) / cellSize);
            }
        }
        for (let j = 0; j < gridSize; j++) {
            for (let i = 0; i < side; i++) {
                const n = j * side + i;
                const du = Math.abs(gridU[n + side] - gridU[n]) * baseWidth;
                const dv = Math.abs(gridV[n + side] - gridV[n]) * baseHeight;
                density = Math.max(density, Math.hypot(du, dv) / cellSize);
            }
        }

        if (!(density > 1)) {
            return 0;
        }

        const maxFactor = density * OVERVIEW_RESOLUTION_TOLERANCE;

        for (let i = this.images.length - 1; i > 0; i--) {
            if (baseWidth / this.images[i].getWidth() <= maxFactor) {
                return i;
            }
        }

        return 0;
    }

    private _warpRasters(
        rasters: TypedArray[],
        grid: TileGrid,
        imgWidth: number,
        imgHeight: number,
        window: [number, number, number, number],
        tileSize: number
    ): Float32Array[] {
        const [winX0, winY0, winX1, winY1] = window;
        const winWidth = winX1 - winX0;
        const winHeight = winY1 - winY0;

        const { gridU, gridV, gridSize } = grid;
        const side = gridSize + 1;
        const cellSize = tileSize / gridSize;
        const bilinear = this.options.renderOptions?.resampleMethod === "bilinear";

        const nodata = this._getEffectiveNoData();
        const checkNoData = nodata !== null ? getFastNoDataChecker(nodata) : null;

        const out: Float32Array[] = [];
        for (let b = 0; b < rasters.length; b++) {
            out.push(new Float32Array(tileSize * tileSize).fill(NaN));
        }

        for (let y = 0; y < tileSize; y++) {
            // Grid nodes sit on tile pixel borders, so pixel centers are used here
            const gy = (y + 0.5) / cellSize;
            const j0 = Math.min(gridSize - 1, gy | 0);
            const fy = gy - j0;

            for (let x = 0; x < tileSize; x++) {
                const gx = (x + 0.5) / cellSize;
                const i0 = Math.min(gridSize - 1, gx | 0);
                const fx = gx - i0;

                // Bilinear interpolation of the source position between the grid nodes
                const n00 = j0 * side + i0;
                const n10 = n00 + 1;
                const n01 = n00 + side;
                const n11 = n01 + 1;

                const w00 = (1 - fx) * (1 - fy);
                const w10 = fx * (1 - fy);
                const w01 = (1 - fx) * fy;
                const w11 = fx * fy;

                const u = gridU[n00] * w00 + gridU[n10] * w10 + gridU[n01] * w01 + gridU[n11] * w11;
                const v = gridV[n00] * w00 + gridV[n10] * w10 + gridV[n01] * w01 + gridV[n11] * w11;

                if (u < 0 || u >= 1 || v < 0 || v >= 1) continue;

                // Source position inside the window, in pixel centers
                const sx = u * imgWidth - winX0 - 0.5;
                const sy = v * imgHeight - winY0 - 0.5;

                const outIdx = y * tileSize + x;

                if (bilinear) {
                    const x0 = Math.floor(sx);
                    const y0 = Math.floor(sy);
                    const tx = sx - x0;
                    const ty = sy - y0;
                    const x1 = Math.min(winWidth - 1, x0 + 1);
                    const y1 = Math.min(winHeight - 1, y0 + 1);
                    const cx0 = Math.max(0, Math.min(winWidth - 1, x0));
                    const cy0 = Math.max(0, Math.min(winHeight - 1, y0));

                    const i00 = cy0 * winWidth + cx0;
                    const i10 = cy0 * winWidth + x1;
                    const i01 = y1 * winWidth + cx0;
                    const i11 = y1 * winWidth + x1;

                    for (let b = 0; b < rasters.length; b++) {
                        const src = rasters[b];
                        const v00 = src[i00],
                            v10 = src[i10],
                            v01 = src[i01],
                            v11 = src[i11];

                        if (
                            checkNoData
                                ? checkNoData(v00) || checkNoData(v10) || checkNoData(v01) || checkNoData(v11)
                                : Number.isNaN(v00) || Number.isNaN(v10) || Number.isNaN(v01) || Number.isNaN(v11)
                        ) {
                            continue;
                        }

                        out[b][outIdx] = (v00 * (1 - tx) + v10 * tx) * (1 - ty) + (v01 * (1 - tx) + v11 * tx) * ty;
                    }
                } else {
                    const px = Math.max(0, Math.min(winWidth - 1, Math.round(sx)));
                    const py = Math.max(0, Math.min(winHeight - 1, Math.round(sy)));
                    const srcIdx = py * winWidth + px;

                    for (let b = 0; b < rasters.length; b++) {
                        const val = rasters[b][srcIdx];
                        if (checkNoData ? checkNoData(val) : Number.isNaN(val)) continue;
                        out[b][outIdx] = val;
                    }
                }
            }
        }

        return out;
    }

    /**
     * NoData value that masks pixels, or null when masking is turned off.
     * Layer options take precedence over the file metadata.
     */
    private _getEffectiveNoData(): number | null {
        let userNoData: number | null | undefined = undefined;

        if (this.options.renderOptions?.nodata !== undefined) {
            userNoData = parseNoDataValue(this.options.renderOptions.nodata);
        } else if (this.options.renderOptions?.noData !== undefined) {
            userNoData = parseNoDataValue(this.options.renderOptions.noData);
        } else if (this.options.nodata !== undefined) {
            userNoData = parseNoDataValue(this.options.nodata);
        } else if (this.options.noData !== undefined) {
            userNoData = parseNoDataValue(this.options.noData);
        }

        if (userNoData !== undefined) {
            return userNoData !== null && !Number.isNaN(userNoData) ? userNoData : null;
        }

        const metaNoData = this.metadata?.noData;
        return metaNoData !== null && metaNoData !== undefined && !Number.isNaN(metaNoData) ? metaNoData : null;
    }

    private _getFillValue(): number | undefined {
        return this._getEffectiveNoData() ?? undefined;
    }

    private _checkIfReversed(image: GeoTIFFImage): boolean {
        const fileDirectory = image.getFileDirectory();

        const pixelScale = fileDirectory.getValue("ModelPixelScale");

        if (pixelScale && pixelScale[1] < 0) {
            return true;
        }

        const transform = fileDirectory.getValue("ModelTransformation");

        if (transform && transform[7] > transform[3]) {
            return true;
        }

        return false;
    }

    /**
     * Cleans up worker pool and cached images.
     */
    public destroy(): void {
        if (this._ownsWorkerPool && this.workerPool) {
            this.workerPool.destroy();
            this.workerPool = null;
        }
        this.images = [];
        this.source = null;
        this.isReady = false;
    }
}