layer/GeoTIFFLayer.tstypescript
import type { EventsHandler } from "../Events";
import { Material } from "./Material";
import { BaseTileMaterialLayer } from "./BaseTileMaterialLayer";
import type { LayerEventsList } from "./Layer";
import type { Segment } from "../segment/Segment";
import type { TypedArray } from "geotiff";
import { RENDERING } from "../quadTree/quadTree";
import {
    createImageData,
    getFastNoDataChecker,
    GeoTIFFReader,
    parseNoDataValue,
    renderMultiBandToImageData,
    renderRgbRastersToImageData,
    renderSingleBandToImageData,
    type IGeoTIFFLayerParams,
    type IGeoTIFFMetadata,
    type IGeoTIFFRenderOptions,
    type DecodedTileData
} from "./geotiff";

interface ICachedTile {
    image: ImageData;
    renderOptionsVersion: number;
}

interface IPendingTile {
    material: Material;
    forceLoading: boolean;
}

type GeoTIFFEventsList = ["load", "loadend", "ready", "error"];
type GeoTIFFEventsType = EventsHandler<GeoTIFFEventsList> & EventsHandler<LayerEventsList>;

const GEOTIFF_EVENTS: GeoTIFFEventsList = [
    /** Triggered when a tile image is loaded and applied */
    "load",
    /** Triggered when pending tile requests finish */
    "loadend",
    /** Triggered when GeoTIFF metadata and overviews are ready */
    "ready",
    /** Triggered on any load error */
    "error"
];

/**
 * GeoTIFFLayer renders Cloud Optimized GeoTIFF (COG) and standard GeoTIFF rasters
 * onto the OpenGlobus 3D globe using progressive overview pyramid tiles.
 */
export class GeoTIFFLayer extends BaseTileMaterialLayer {
    public override events: GeoTIFFEventsType;

    protected _reader: GeoTIFFReader;
    protected _renderOptions: IGeoTIFFRenderOptions;
    protected _tileSize: number;
    protected _tileCache: Map<string, ICachedTile>;
    protected _rasterCache: Map<string, DecodedTileData>;
    protected _maxCacheSize: number;
    protected _activeRequestsCount: number;
    protected _readSamples: number[];
    protected _readyPromise: Promise<IGeoTIFFMetadata>;
    protected _activeMaterials: Set<Material>;
    protected _renderOptionsVersion: number;
    protected _debounceUpdateTimer: any;
    protected _pendingsQueue: IPendingTile[];

    static MAX_REQUESTS: number = 8;

    constructor(name: string | null, options: IGeoTIFFLayerParams = {}) {
        super(name, { waitForParentMaterial: false, ...options });

        // @ts-ignore
        this.events = this.events.registerNames(GEOTIFF_EVENTS);

        this._reader = new GeoTIFFReader(options);
        const initialNodata =
            options.renderOptions?.nodata !== undefined
                ? parseNoDataValue(options.renderOptions.nodata)
                : options.renderOptions?.noData !== undefined
                  ? parseNoDataValue(options.renderOptions.noData)
                  : options.nodata !== undefined
                    ? parseNoDataValue(options.nodata)
                    : options.noData !== undefined
                      ? parseNoDataValue(options.noData)
                      : undefined;

        this._renderOptions = options.renderOptions ? { ...options.renderOptions } : {};

        if (initialNodata !== undefined) {
            this._renderOptions.nodata = initialNodata;
        }

        this._tileSize = options.tileSize || 256;
        this._tileCache = new Map();
        this._rasterCache = new Map();
        this._maxCacheSize = options.cacheSize || 500;
        this._activeRequestsCount = 0;
        this._readSamples = [];
        this._activeMaterials = new Set();
        this._renderOptionsVersion = 0;
        this._debounceUpdateTimer = null;
        this._pendingsQueue = [];
        this._updateReadSamples();

        const src = options.src || options.url;
        if (src) {
            this._readyPromise = this._initSource(src, options);
            this._readyPromise.catch(() => {
                //empty
            });
        } else {
            this._readyPromise = Promise.reject(new Error("No GeoTIFF source provided."));
            this._readyPromise.catch(() => {
                //empty
            });
        }
    }

    public static async fromUrl(name: string, url: string, options: IGeoTIFFLayerParams = {}): Promise<GeoTIFFLayer> {
        const layer = new GeoTIFFLayer(name, { ...options, url });
        await layer.whenReady();
        return layer;
    }

    public static async fromBlob(
        name: string,
        blob: Blob | File,
        options: IGeoTIFFLayerParams = {}
    ): Promise<GeoTIFFLayer> {
        const layer = new GeoTIFFLayer(name, { ...options, src: blob });
        await layer.whenReady();
        return layer;
    }

    public override get instanceName(): string {
        return "GeoTIFFLayer";
    }

    public override get isIdle(): boolean {
        return super.isIdle && this._activeRequestsCount === 0 && this._pendingsQueue.length === 0;
    }

    public get reader(): GeoTIFFReader {
        return this._reader;
    }

    public get metadata(): IGeoTIFFMetadata | null {
        return this._reader.metadata;
    }

    public get nodata(): number | null | undefined {
        if (this._renderOptions.nodata !== undefined) {
            return parseNoDataValue(this._renderOptions.nodata);
        }
        return this._reader.metadata?.noData ?? null;
    }

    public set nodata(val: number | null | "nan" | "NaN" | undefined) {
        const parsed = parseNoDataValue(val);
        if (parsed !== undefined) {
            this._renderOptions.nodata = parsed;
        } else {
            delete this._renderOptions.nodata;
        }
        this._reapplyRenderOptions();
    }

    public async whenReady(): Promise<IGeoTIFFMetadata> {
        return this._readyPromise;
    }

    /**
     * Updates rendering options and re-renders active textures in place.
     */
    public setRenderOptions(renderOptions: IGeoTIFFRenderOptions, fullUpdate: boolean = false): void {
        const prevSamples = this._readSamples.slice();

        // Preserve nodata and any existing top-level options unless explicitly provided
        const currentNodata = this._renderOptions.nodata;
        const nextNodata =
            renderOptions.nodata !== undefined
                ? parseNoDataValue(renderOptions.nodata)
                : renderOptions.noData !== undefined
                  ? parseNoDataValue(renderOptions.noData)
                  : undefined;

        this._renderOptions = { ...this._renderOptions, ...renderOptions };

        if (nextNodata !== undefined) {
            this._renderOptions.nodata = nextNodata;
        } else if (currentNodata !== undefined) {
            this._renderOptions.nodata = currentNodata;
        }

        if (renderOptions.single) {
            delete this._renderOptions.multi;
            delete this._renderOptions.convertToRGB;
        } else if (renderOptions.multi) {
            delete this._renderOptions.single;
            delete this._renderOptions.convertToRGB;
        } else if (renderOptions.convertToRGB) {
            delete this._renderOptions.single;
            delete this._renderOptions.multi;
        }

        if (this._reader.metadata) {
            this._normalizeRenderOptions(this._reader.metadata);
        }
        if (this._reader.options) {
            this._reader.options.renderOptions = this._renderOptions;
        }
        this._updateReadSamples();

        this._renderOptionsVersion++;

        const samplesChanged =
            prevSamples.length !== this._readSamples.length ||
            prevSamples.some((s, idx) => s !== this._readSamples[idx]);

        if (!samplesChanged) {
            // Real-time styling update (stretch min/max, colormap, RGB contrast):
            // Re-render in-memory rasters instantly without reloading tiles or destroying textures.
            this._reapplyRenderOptions(fullUpdate);
        } else {
            // Sample/band selection changed: cancel pending requests, clear caches, and clear quadtree materials
            this.abortLoading();
            this.clearCache();
            if (this._planet) {
                this._planet.quadTreeStrategy.clearLayerMaterial(this, false);
                this._planet.renderer?.requestRedraw();
            }
        }
    }

    public override createMaterial(segment: Segment): Material {
        const mat = super.createMaterial(segment);
        this._activeMaterials.add(mat);
        return mat;
    }

    public override clearMaterial(material: Material): void {
        this._activeMaterials.delete(material);
        super.clearMaterial(material);
    }

    /**
     * Clears decoded in-memory tile and raster caches.
     */
    public clearCache(): void {
        if (this._debounceUpdateTimer !== null) {
            clearTimeout(this._debounceUpdateTimer);
            this._debounceUpdateTimer = null;
        }
        this._tileCache.clear();
        this._rasterCache.clear();
        this._activeMaterials.clear();
    }

    public override clear(): void {
        this.clearCache();
        super.clear();
    }

    public override redraw(): void {
        this.clearCache();
        super.redraw();
    }

    private _reapplyRenderOptions(fullUpdate: boolean = false): void {
        if (this._debounceUpdateTimer !== null) {
            clearTimeout(this._debounceUpdateTimer);
            this._debounceUpdateTimer = null;
        }

        if (!this._planet) {
            // Update cached images for in-memory rasters
            for (const [cacheKey, tileData] of this._rasterCache.entries()) {
                this._refreshCachedTile(cacheKey, tileData);
            }
            return;
        }

        const lid = this.__id;
        const qts = this._planet.quadTreeStrategy;
        const renderedNodes = qts._renderedNodes;

        if (!renderedNodes || renderedNodes.length === 0) {
            for (const [cacheKey, tileData] of this._rasterCache.entries()) {
                this._refreshCachedTile(cacheKey, tileData);
            }
            this._planet.renderer?.requestRedraw();
            return;
        }

        const renderedKeys = new Set<string>();

        // 1. Instantly re-render currently visible/rendered tiles using in-memory rasters without flicker
        for (let i = 0, len = renderedNodes.length; i < len; i++) {
            const node = renderedNodes[i];
            const seg = node.segment;
            if (!seg) continue;

            const mat = seg.materials[lid];
            const cacheKey = `${seg.tileZoom}_${seg.tileX}_${seg.tileY}`;
            renderedKeys.add(cacheKey);

            const tileData = this._rasterCache.get(cacheKey);
            if (tileData) {
                const image = this._refreshCachedTile(cacheKey, tileData);
                if (image && mat && mat.isReady && mat.texture) {
                    mat.applyImage(image);
                }
            }
        }

        // 2. Re-render all other in-memory rasters (ancestors, previously visited tiles) into _tileCache
        for (const [cacheKey, tileData] of this._rasterCache.entries()) {
            if (!renderedKeys.has(cacheKey)) {
                this._refreshCachedTile(cacheKey, tileData);
            }
        }

        // 3. Prune any image in _tileCache that no longer has corresponding raw raster data
        for (const key of this._tileCache.keys()) {
            if (!this._rasterCache.has(key)) {
                this._tileCache.delete(key);
            }
        }

        // 4. Update WebGL textures for offscreen materials:
        // Instead of traversing thousands of nodes across the entire Earth on every frame,
        // we directly iterate the materials created for this layer (_activeMaterials).
        const updateOffscreenMaterials = () => {
            if (this._activeMaterials.size > 0) {
                for (const mat of this._activeMaterials) {
                    if (!mat.segment || !mat.layer) {
                        this._activeMaterials.delete(mat);
                        continue;
                    }
                    if (mat.isReady && mat.texture) {
                        const seg = mat.segment;
                        const cacheKey = `${seg.tileZoom}_${seg.tileX}_${seg.tileY}`;
                        if (!renderedKeys.has(cacheKey)) {
                            const cached = this._tileCache.get(cacheKey);
                            if (cached) {
                                mat.applyImage(cached.image);
                            }
                        }
                    }
                }
            }
        };

        if (fullUpdate) {
            updateOffscreenMaterials();
        } else {
            // Debounce offscreen WebGL texture uploads by 100ms so continuous mouse dragging stays at 60 fps
            this._debounceUpdateTimer = setTimeout(() => {
                this._debounceUpdateTimer = null;
                updateOffscreenMaterials();
            }, 100);
        }

        this._planet.renderer?.requestRedraw();
    }

    private async _initSource(
        src: string | Blob | File | ArrayBuffer,
        options: IGeoTIFFLayerParams
    ): Promise<IGeoTIFFMetadata> {
        try {
            const meta = await this._reader.init(src);

            // Configure layer bounds
            if (this._reader.extentWgs84) {
                this.setExtent(this._reader.extentWgs84);
            }

            if (options.useImageCountAsMaximumLevel) {
                this.maxNativeZoom = Math.min(this.maxNativeZoom, meta.overviewCount - 1);
            }

            // Set up default rendering mode if not configured
            this._normalizeRenderOptions(meta);
            this._updateReadSamples();

            this.events.dispatch(this.events.ready, meta);
            this.redraw();
            return meta;
        } catch (err: any) {
            this.events.dispatch(this.events.error, err);
            throw err;
        }
    }

    private _normalizeRenderOptions(meta: IGeoTIFFMetadata): void {
        const samples = meta.samplesPerPixel;

        if (this._renderOptions.single) {
            const band = this._renderOptions.single.band ?? 1;
            const bandMeta = meta.bands[band] || meta.bands[1];
            if (!this._renderOptions.single.domain && bandMeta) {
                this._renderOptions.single.domain = [bandMeta.min, bandMeta.max];
            }
            if (!this._renderOptions.single.colorScale) {
                this._renderOptions.single.colorScale = "viridis";
            }
        } else if (this._renderOptions.multi) {
            const { r, g, b } = this._renderOptions.multi;
            if (r && r.min === undefined && meta.bands[r.band ?? 1]) {
                r.min = meta.bands[r.band ?? 1].min;
                r.max = meta.bands[r.band ?? 1].max;
            }
            if (g && g.min === undefined && meta.bands[g.band ?? 2]) {
                g.min = meta.bands[g.band ?? 2].min;
                g.max = meta.bands[g.band ?? 2].max;
            }
            if (b && b.min === undefined && meta.bands[b.band ?? 3]) {
                b.min = meta.bands[b.band ?? 3].min;
                b.max = meta.bands[b.band ?? 3].max;
            }
        } else if (samples >= 3) {
            this._renderOptions.convertToRGB = this._renderOptions.convertToRGB ?? true;
        } else {
            const bandMeta = meta.bands[1];
            this._renderOptions.single = {
                band: 1,
                colorScale: "viridis",
                domain: bandMeta ? [bandMeta.min, bandMeta.max] : [0, 255]
            };
        }

        if (this._renderOptions.nodata === undefined && meta.noData !== null) {
            this._renderOptions.nodata = meta.noData;
        }
    }

    private _updateReadSamples(): void {
        if (this._renderOptions.multi) {
            const { r, g, b } = this._renderOptions.multi;
            this._readSamples = [(r?.band ?? 1) - 1, (g?.band ?? 2) - 1, (b?.band ?? 3) - 1];
        } else if (this._renderOptions.convertToRGB) {
            this._readSamples = [0, 1, 2];
        } else if (this._renderOptions.single) {
            const expr = this._renderOptions.single.expression;
            if (expr) {
                const matches = expr.matchAll(/b(\d+)/g);
                const sampleSet = new Set<number>();
                for (const m of matches) {
                    const bandNum = parseInt(m[1], 10);
                    if (bandNum > 0) sampleSet.add(bandNum - 1);
                }
                this._readSamples =
                    sampleSet.size > 0
                        ? Array.from(sampleSet).sort((a, b) => a - b)
                        : [(this._renderOptions.single.band ?? 1) - 1];
            } else {
                this._readSamples = [(this._renderOptions.single.band ?? 1) - 1];
            }
        } else {
            this._readSamples = [0];
        }
    }

    public override loadMaterial(material: Material, forceLoading: boolean = false): void {
        this._activeMaterials.add(material);
        const seg = material.segment;

        if (!material.texture) {
            if (this._isBaseLayer) {
                material.texture = seg.getDefaultTexture();
            } else {
                material.texture = seg.planet.transparentTexture;
            }
        }

        if (!this._reader.isReady) {
            material.isLoading = false;
            material.isReady = false;
            return;
        }

        const tileExtent = seg.getExtentLonLat();
        if (!this._extent.overlaps(tileExtent)) {
            material.texture = this._isBaseLayer ? seg.getDefaultTexture() : seg.planet.transparentTexture;
            material.textureNotExists();
            return;
        }

        const cacheKey = `${seg.tileZoom}_${seg.tileX}_${seg.tileY}`;

        material.isReady = false;
        material.isLoading = true;

        if (this._tileCache.has(cacheKey) || this._rasterCache.has(cacheKey)) {
            requestAnimationFrame(() => {
                if (material.isLoading) {
                    const image = this._getCachedTileImage(cacheKey);
                    if (image) {
                        material.applyImage(image);
                    } else {
                        this._enqueueTile(material, cacheKey, forceLoading);
                    }
                }
            });
            return;
        }

        this._enqueueTile(material, cacheKey, forceLoading);
    }

    protected _enqueueTile(material: Material, cacheKey: string, forceLoading: boolean): void {
        if (this._activeRequestsCount >= GeoTIFFLayer.MAX_REQUESTS) {
            this._pendingsQueue.push({ material, forceLoading });
            return;
        }

        this._requestTile(material, cacheKey);
    }

    protected _whilePendings(): IPendingTile | null {
        while (this._pendingsQueue.length) {
            const pending = this._pendingsQueue.pop()!;
            const material = pending.material;
            const seg = material.segment;

            if (!material.isLoading || !seg || !seg.node) {
                continue;
            }

            if (pending.forceLoading || (seg.initialized && seg.node.getState() === RENDERING)) {
                return pending;
            }

            material.isLoading = false;
        }

        return null;
    }

    protected _dequeueRequest(): void {
        while (this._activeRequestsCount < GeoTIFFLayer.MAX_REQUESTS) {
            const pending = this._whilePendings();
            if (!pending) return;

            const seg = pending.material.segment;
            const cacheKey = `${seg.tileZoom}_${seg.tileX}_${seg.tileY}`;

            const image = this._getCachedTileImage(cacheKey);

            if (image) {
                pending.material.applyImage(image);
                continue;
            }

            this._requestTile(pending.material, cacheKey);
        }
    }

    private _removeFromQueue(material: Material): void {
        for (let i = 0, len = this._pendingsQueue.length; i < len; i++) {
            if (this._pendingsQueue[i].material === material) {
                this._pendingsQueue.splice(i, 1);
                return;
            }
        }
    }

    private _getCachedTileImage(cacheKey: string): ImageData | null {
        const cached = this._tileCache.get(cacheKey);
        const tileData = this._rasterCache.get(cacheKey);

        if (cached) {
            if (tileData && cached.renderOptionsVersion !== this._renderOptionsVersion) {
                const refreshed = this._refreshCachedTile(cacheKey, tileData);
                if (refreshed) {
                    return refreshed;
                }
            }
            return cached.image;
        }

        if (tileData) {
            return this._refreshCachedTile(cacheKey, tileData);
        }

        return null;
    }

    private _requestTile(material: Material, cacheKey: string): void {
        const seg = material.segment;

        this._activeRequestsCount++;

        this._reader
            .readTileRasters(
                seg.getExtentLonLat(),
                seg.tileZoom,
                this._tileSize,
                this._readSamples.slice(),
                seg._projection
            )
            .then((tileData) => {
                this._activeRequestsCount--;

                if (this._activeRequestsCount < 0) {
                    this._activeRequestsCount = 0;
                }

                this._dequeueRequest();

                if (!material.isLoading) return;

                if (!tileData || !tileData.rasters || tileData.rasters.length === 0) {
                    material.texture = this._isBaseLayer ? seg.getDefaultTexture() : seg.planet.transparentTexture;
                    material.textureNotExists();
                    return;
                }

                this._addRasterToCache(cacheKey, tileData);

                const image = this._refreshCachedTile(cacheKey, tileData);
                if (!image) {
                    material.texture = this._isBaseLayer ? seg.getDefaultTexture() : seg.planet.transparentTexture;
                    material.textureNotExists();
                    return;
                }

                material.applyImage(image);

                const e = this.events.load;
                if (e && e.handlers.length) {
                    this.events.dispatch(e, material);
                }
            })
            .catch((err) => {
                this._activeRequestsCount--;

                if (this._activeRequestsCount < 0) {
                    this._activeRequestsCount = 0;
                }

                this._dequeueRequest();

                if (material.isLoading) {
                    material.texture = this._isBaseLayer ? seg.getDefaultTexture() : seg.planet.transparentTexture;
                    material.textureNotExists();
                    console.error("[GeoTIFFLayer] Error loading tile:", err);
                }
            });
    }

    /**
     * Renders the tile rasters into an image that goes straight into the tile texture.
     * The previous image of the tile is reused, so restyling does not allocate.
     */
    private _renderTileImage(tileData: DecodedTileData, existingImage?: ImageData): ImageData | null {
        let image = existingImage;
        if (!image || image.width !== tileData.width || image.height !== tileData.height) {
            image = createImageData(tileData.width, tileData.height);
        }

        return this._renderToImageData(tileData, image);
    }

    /**
     * Renders the tile and puts it into the cache, reusing the image that is already there.
     */
    private _refreshCachedTile(cacheKey: string, tileData: DecodedTileData): ImageData | null {
        const image = this._renderTileImage(tileData, this._tileCache.get(cacheKey)?.image);
        if (!image) return null;

        this._addToCache(cacheKey, image);

        return image;
    }

    private _renderToImageData(tileData: DecodedTileData, outImageData?: ImageData): ImageData | null {
        const { rasters, width, height } = tileData;
        const rawNodata =
            this._renderOptions.nodata !== undefined
                ? this._renderOptions.nodata
                : (this._reader.metadata?.noData ?? null);
        const nodata = parseNoDataValue(rawNodata) ?? null;

        if (this._renderOptions.convertToRGB && rasters.length >= 3) {
            return renderRgbRastersToImageData(rasters, width, height, nodata, outImageData);
        }

        if (this._renderOptions.multi && rasters.length >= 3) {
            return renderMultiBandToImageData(
                rasters[0],
                rasters[1],
                rasters[2],
                width,
                height,
                this._renderOptions.multi,
                nodata,
                outImageData
            );
        }

        if (this._renderOptions.single && rasters.length > 0) {
            const expr = this._renderOptions.single.expression;
            let targetRaster = rasters[0];
            if (expr && rasters.length > 0) {
                const evaluated = this._evaluateBandExpression(rasters, width, height, expr, nodata);
                if (evaluated) {
                    targetRaster = evaluated;
                }
            }
            return renderSingleBandToImageData(
                targetRaster,
                width,
                height,
                this._renderOptions.single,
                nodata,
                outImageData
            );
        }

        if (rasters.length >= 3) {
            return renderRgbRastersToImageData(rasters, width, height, nodata, outImageData);
        } else if (rasters.length > 0) {
            return renderSingleBandToImageData(rasters[0], width, height, undefined, nodata, outImageData);
        }

        return null;
    }

    private _evaluateBandExpression(
        rasters: TypedArray[],
        width: number,
        height: number,
        expression: string,
        nodata: number | null
    ): Float32Array | null {
        try {
            const cleanExpr = expression.trim();
            const pixelCount = width * height;
            const result = new Float32Array(pixelCount);
            const hasNoData = nodata !== null && nodata !== undefined && !Number.isNaN(nodata);
            const checkNoData = hasNoData ? getFastNoDataChecker(nodata) : null;

            // Fast path for NDVI: (bX - bY) / (bX + bY)
            const ndviMatch = cleanExpr.replace(/\s+/g, "").match(/^\(b(\d+)-b(\d+)\)\/\(b\1\+b\2\)$/);
            if (ndviMatch) {
                const nirBand = parseInt(ndviMatch[1], 10);
                const redBand = parseInt(ndviMatch[2], 10);
                const nirSampleIdx = this._readSamples.indexOf(nirBand - 1);
                const redSampleIdx = this._readSamples.indexOf(redBand - 1);

                if (nirSampleIdx !== -1 && redSampleIdx !== -1 && rasters[nirSampleIdx] && rasters[redSampleIdx]) {
                    const nir = rasters[nirSampleIdx];
                    const red = rasters[redSampleIdx];

                    for (let i = 0; i < pixelCount; i++) {
                        const n = nir[i];
                        const r = red[i];
                        if (checkNoData ? checkNoData(n) || checkNoData(r) : Number.isNaN(n) || Number.isNaN(r)) {
                            result[i] = NaN;
                            continue;
                        }
                        const sum = n + r;
                        result[i] = sum !== 0 ? (n - r) / sum : 0;
                    }
                    return result;
                }
            }

            // General expression evaluation across bands
            const bandNumbers = Array.from(
                new Set(Array.from(cleanExpr.matchAll(/b(\d+)/g), (m) => parseInt(m[1], 10)))
            );
            const argNames = bandNumbers.map((b) => `b${b}`);
            const bandRasters: TypedArray[] = [];

            for (let i = 0; i < bandNumbers.length; i++) {
                const idx = this._readSamples.indexOf(bandNumbers[i] - 1);
                const raster = idx !== -1 ? rasters[idx] : undefined;
                if (!raster) return null;
                bandRasters.push(raster);
            }

            const fn = new Function(...argNames, `"use strict"; return (${cleanExpr});`);
            const args = new Array(bandNumbers.length);

            for (let i = 0; i < pixelCount; i++) {
                let isNoData = false;
                for (let k = 0; k < bandNumbers.length; k++) {
                    const val = bandRasters[k][i];
                    if (checkNoData ? checkNoData(val) : Number.isNaN(val)) {
                        isNoData = true;
                        break;
                    }
                    args[k] = val;
                }
                if (isNoData) {
                    result[i] = NaN;
                    continue;
                }
                const v = fn(...args);
                result[i] = Number.isFinite(v) ? v : 0;
            }
            return result;
        } catch (err) {
            console.error("[GeoTIFFLayer] Error evaluating expression:", expression, err);
            return null;
        }
    }

    private _addToCache(key: string, image: ImageData): void {
        if (!this._tileCache.has(key) && this._tileCache.size >= this._maxCacheSize) {
            const firstKey = this._tileCache.keys().next().value;
            if (firstKey !== undefined) {
                this._tileCache.delete(firstKey);
            }
        }
        this._tileCache.set(key, { image, renderOptionsVersion: this._renderOptionsVersion });
    }

    private _addRasterToCache(key: string, data: DecodedTileData): void {
        if (this._rasterCache.size >= this._maxCacheSize) {
            const firstKey = this._rasterCache.keys().next().value;
            if (firstKey !== undefined) {
                this._rasterCache.delete(firstKey);
            }
        }
        this._rasterCache.set(key, data);
    }

    public override abortMaterialLoading(material: Material): void {
        this._activeMaterials.delete(material);
        this._removeFromQueue(material);
        material.isLoading = false;
        material.isReady = false;
    }

    public override abortLoading(): void {
        for (let i = 0, len = this._pendingsQueue.length; i < len; i++) {
            this._pendingsQueue[i].material.isLoading = false;
        }
        this._pendingsQueue = [];
    }

    public override remove(): this {
        this.abortLoading();
        this.clearCache();
        this._reader.destroy();
        super.remove();
        return this;
    }
}