layer_XYZ.ts

import * as mercator from "../mercator";
import { BaseTileMaterialLayer } from "./BaseTileMaterialLayer";
import type { IBaseTileMaterialLayerParams } from "./BaseTileMaterialLayer";
import type { LayerEventsList } from "./Layer";
import { RENDERING } from "../quadTree/quadTree";
import { Segment } from "../segment/Segment";
import { stringTemplate } from "../utils/shared";
import type { EventsHandler } from "../Events";
import { Material } from "./Material";
import type { FetchCache, IResponse } from "../utils/Loader";

export interface IXYZParams extends IBaseTileMaterialLayerParams {
    url?: string;
    subdomains?: string[];
    minNativeZoom?: number;
    maxNativeZoom?: number;
    urlRewrite?: Function;
    /**
     * Fetch RequestCache value
     * https://developer.mozilla.org/en-US/docs/Web/API/Request/cache
     * @default "default"
     */
    cache?: FetchCache;
}

type XYZEventsList = ["load", "loadend"];

type XYZEventsType = EventsHandler<XYZEventsList> & EventsHandler<LayerEventsList>;

/**
 * Represents an imagery tiles source provider.
 * @class
 * @extends {Layer}
 * @param {string} name - Layer name.
 * @param {IXYZParams} options:
 * @param {number} [options.opacity=1.0] - Layer opacity.
 * @param {Array.<string>} [options.subdomains=['a','b','c']] - Subdomains of the tile service.
 * @param {number} [options.minZoom=0] - Minimal visibility zoom level.
 * @param {number} [options.maxZoom=0] - Maximal visibility zoom level.
 * @param {number} [options.minNativeZoom=0] - Minimal available zoom level.
 * @param {number} [options.maxNativeZoom=19] - Maximal available zoom level.
 * @param {string} [options.attribution] - Layer attribution shown in the attribution area.
 * @param {boolean} [options.isBaseLayer=false] - Base layer flag.
 * @param {boolean} [options.visibility=true] - Layer visibility.
 * @param {string} [options.crossOrigin=true] - If true, all tiles will have their crossOrigin attribute set to ''.
 * @param {string} options.url - Tile url source template(see example below).
 * @param {string} options.textureFilter - Texture WebGL filter: NEAREST, LINEAR, MIPMAP, ANISOTROPIC.
 * @param {Function} options.urlRewrite - URL rewrite function.
 *
 * @fires load
 * @fires loadend
 *
 * @example <caption>Creates OpenStreetMap base tile layer</caption>
 * new og.layer.XYZ("OpenStreetMap", {
 *     isBaseLayer: true,
 *     url: "http://b.tile.openstreetmap.org/{z}/{x}/{y}.png",
 *     visibility: true,
 *     attribution: 'Data @ <a href="http://www.openstreetmap.org/">OpenStreetMap</a> contributors, <a href="http://www.openstreetmap.org/copyright">ODbL</a>'
 * });
 */
export class XYZ extends BaseTileMaterialLayer {
    public override events: XYZEventsType;

    /**
     * Tile url source template.
     * @public
     * @type {string}
     */
    public url: string;

    /**
     * @protected
     */
    protected _s: string[];

    /**
     * Rewrites imagery tile url query.
     * @private
     * @param {Segment} segment - Segment to load.
     * @param {string} url - Created url.
     * @returns {string} URL query string.
     */
    protected _urlRewriteCallback: Function | null;

    protected _requestsPeerSubdomains: number;

    protected _requestCount: number;

    protected _cache: FetchCache;

    constructor(name: string | null, options: IXYZParams = {}) {
        super(name, options);

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

        this.url = options.url || "";

        this._s = options.subdomains || ["a", "b", "c"];

        this.minNativeZoom = options.minNativeZoom || 0;

        this.maxNativeZoom = options.maxNativeZoom || 19;

        this._urlRewriteCallback = options.urlRewrite || null;

        this._requestsPeerSubdomains = 4;

        this._requestCount = 0;

        this._cache = options.cache || "default";
    }

    /**
     * @warning Use XYZ.isIdle in requestAnimationFrame(after setVisibility)
     */
    public override get isIdle(): boolean {
        return super.isIdle && this._planet!._tileLoader.getRequestCounter(this) === 0;
    }

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

    /**
     * Abort loading tiles.
     * @public
     */
    public override abortLoading() {
        if (this._planet) {
            this._planet._tileLoader.abort(this);
        }
    }

    /**
     * Sets layer visibility.
     * @public
     * @param {boolean} visibility - Layer visibility.
     */
    public override setVisibility(visibility: boolean) {
        if (visibility !== this._visibility) {
            super.setVisibility(visibility);
            if (!visibility) {
                this.abortLoading();
            }
        }
    }

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

    /**
     * Sets imagery tiles url source template.
     * @public
     * @param {string} url - Url template.
     * @example
     * http://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png
     * where {z}, {x} and {y} - replaces by current tile values, {s} - random domain.
     */
    public setUrl(url: string) {
        this.url = url;
    }

    public _checkSegment(segment: Segment) {
        return segment._projection.id === this._planet!.quadTreeStrategy.projection.id;
    }

    /**
     * Start to load tile material.
     * @public
     * @virtual
     * @param {Material} material - Loads current material.
     * @param {boolean} [forceLoading=false] -
     */
    public override loadMaterial(material: Material, forceLoading: boolean = false) {
        let seg = material.segment;

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

        // Q: Maybe we should change "<2" to material.segment.tileZoom < (material.layer.minZoom + 1)
        if (this._planet!.layerLock.isFree() || material.segment.tileZoom < 2) {
            material.isReady = false;
            material.isLoading = true;

            if (this._checkSegment(seg)) {
                material.loadingAttempts++;

                this._planet!._tileLoader.load(
                    {
                        sender: this,
                        src: this._getHTTPRequestString(material.segment),
                        type: "imageBitmap",
                        filter: () => (seg.initialized && seg.node.getState() === RENDERING) || forceLoading,
                        options: {
                            cache: this._cache
                        }
                    },
                    (response: IResponse) => {
                        if (response.status === "ready") {
                            if (material.isLoading) {
                                let e = this.events.load!;
                                if (e.handlers.length) {
                                    this.events.dispatch(e, material);
                                }
                                material.applyImage(response.data);
                                response.data = null;
                            }
                        } else if (response.status === "abort") {
                            material.isLoading = false;
                        } else if (response.status === "error") {
                            if (material.isLoading) {
                                material.textureNotExists();
                            }
                        }
                    }
                );
            } else {
                material.textureNotExists();
            }
        }
    }

    /**
     * Creates query url.
     * @protected
     * @virtual
     * @param {Segment} segment - Creates specific url for current segment.
     * @returns {string} URL string.
     */
    protected _createUrl(segment: Segment): string {
        return stringTemplate(this.url, {
            s: this._getSubdomain(),
            x: segment.tileX.toString(),
            y: segment.tileY.toString(),
            z: segment.tileZoom.toString()
        });
    }

    protected _getSubdomain(): string {
        this._requestCount++;
        return this._s[
            Math.floor(
                (this._requestCount % (this._requestsPeerSubdomains * this._s.length)) / this._requestsPeerSubdomains
            )
        ];
    }

    /**
     * Returns actual url query string.
     * @protected
     * @param {Segment} segment - Segment that loads image data.
     * @returns {string} URL string.
     */
    protected _getHTTPRequestString(segment: Segment) {
        return this._urlRewriteCallback ? this._urlRewriteCallback(segment, this.url) : this._createUrl(segment);
    }

    /**
     * Sets url rewrite callback, used for custom url rewriting for every tile loading.
     * @public
     * @param {Function} ur - The callback that returns tile custom created url.
     */
    public setUrlRewriteCallback(ur: Function) {
        this._urlRewriteCallback = ur;
    }

    //
    // Befor baseTileMaterialLayer was added
    //
    // public override applyMaterial(material: Material, forceLoading: boolean = false): NumberArray4 {
    //     if (this.waitForParentMaterial) {
    //         return this._apllyMaterialDefault(material, forceLoading);
    //     } else {
    //         return this._applyMaterialFast(material, forceLoading);
    //     }
    // }
    //
    // protected _apllyMaterialDefault(material: Material, forceLoading: boolean = false): NumberArray4 {
    //     if (material.isReady) {
    //         return material.texOffset;
    //     } else if (material.segment.tileZoom < this.minNativeZoom) {
    //         material.textureNotExists();
    //     } else {
    //         let segment = material.segment;
    //         let layerId = this.__id;
    //
    //         if (segment.passReady) {
    //             let node = segment.node;
    //             let targetNode = null;
    //
    //             while (node) {
    //                 const seg = node.segment;
    //
    //                 if (seg.tileZoom <= this.maxNativeZoom) {
    //                     const mat = seg.materials[layerId];
    //                     if (!mat || !mat.isReady) {
    //                         targetNode = node;
    //                     }
    //                 }
    //
    //                 node = node.parentNode!;
    //             }
    //
    //             if (targetNode) {
    //                 const seg = targetNode.segment;
    //
    //                 let mat = seg.materials[layerId];
    //                 if (!mat) {
    //                     mat = seg.materials[layerId] = this.createMaterial(seg);
    //                 }
    //
    //                 if (!mat.isReady && !mat.isLoading) {
    //                     this.loadMaterial(mat, targetNode === segment.node ? forceLoading : true);
    //                 }
    //             }
    //         }
    //
    //         let pn = segment.node;
    //         let psegm: Material | null = null;
    //         while (pn) {
    //             const pm = pn.segment.materials[layerId];
    //             if (pm && pm.isReady && pm.textureExists) {
    //                 psegm = pm;
    //                 break;
    //             }
    //             pn = pn.parentNode!;
    //         }
    //
    //         if (psegm && pn) {
    //             material.appliedNode = pn;
    //             material.appliedNodeId = pn.nodeId;
    //             material.texture = psegm.texture;
    //             let dZ2 = 1.0 / (2 << (segment.tileZoom - pn.segment.tileZoom - 1));
    //             material.texOffset[0] = segment.tileX * dZ2 - pn.segment.tileX;
    //             material.texOffset[1] = segment.tileY * dZ2 - pn.segment.tileY;
    //             material.texOffset[2] = dZ2;
    //             material.texOffset[3] = dZ2;
    //         } else {
    //             material.texture = segment.planet.transparentTexture;
    //             material.texOffset[0] = 0.0;
    //             material.texOffset[1] = 0.0;
    //             material.texOffset[2] = 1.0;
    //             material.texOffset[3] = 1.0;
    //         }
    //     }
    //
    //     return material.texOffset;
    // }
    //
    // protected _applyMaterialFast(material: Material, forceLoading: boolean = false): NumberArray4 {
    //     if (material.isReady) {
    //         return material.texOffset;
    //     } else if (material.segment.tileZoom < this.minNativeZoom) {
    //         material.textureNotExists();
    //     } else {
    //         let segment = material.segment,
    //             pn = segment.node,
    //             notEmpty = false;
    //
    //         let mId = this.__id;
    //         let psegm = material;
    //         while (pn.parentNode) {
    //             pn = pn.parentNode;
    //             psegm = pn.segment.materials[mId];
    //             if (psegm && psegm.textureExists) {
    //                 notEmpty = true;
    //                 break;
    //             }
    //         }
    //
    //         if (segment.passReady) {
    //             let maxNativeZoom = (material.layer as XYZ).maxNativeZoom;
    //             if (pn.segment.tileZoom === maxNativeZoom) {
    //                 material.textureNotExists();
    //             } else if (material.segment.tileZoom <= maxNativeZoom) {
    //                 !material.isLoading && !material.isReady && this.loadMaterial(material, forceLoading);
    //             } else {
    //                 let pn = segment.node;
    //                 while (pn.segment.tileZoom > (material.layer as XYZ).maxNativeZoom) {
    //                     pn = pn.parentNode!;
    //                 }
    //                 let pnm = pn.segment.materials[material.layer.__id];
    //                 if (pnm) {
    //                     !pnm.isLoading && !pnm.isReady && this.loadMaterial(pnm, true);
    //                 } else {
    //                     pnm = pn.segment.materials[material.layer.__id] = material.layer.createMaterial(pn.segment);
    //                     this.loadMaterial(pnm, true);
    //                 }
    //             }
    //         }
    //
    //         if (notEmpty) {
    //             material.appliedNode = pn;
    //             material.appliedNodeId = pn.nodeId;
    //             material.texture = psegm.texture;
    //             let dZ2 = 1.0 / (2 << (segment.tileZoom - pn.segment.tileZoom - 1));
    //             material.texOffset[0] = segment.tileX * dZ2 - pn.segment.tileX;
    //             material.texOffset[1] = segment.tileY * dZ2 - pn.segment.tileY;
    //             material.texOffset[2] = dZ2;
    //             material.texOffset[3] = dZ2;
    //         } else {
    //             material.texture = segment.planet.transparentTexture;
    //             material.texOffset[0] = 0.0;
    //             material.texOffset[1] = 0.0;
    //             material.texOffset[2] = 1.0;
    //             material.texOffset[3] = 1.0;
    //         }
    //     }
    //
    //     return material.texOffset;
    // }

    /**
     * @protected
     */
    protected override _correctFullExtent() {
        let e = this._extent,
            em = this._extentMerc;
        let ENLARGE_MERCATOR_LON = mercator.POLE + 50000;
        let ENLARGE_MERCATOR_LAT = mercator.POLE + 50000;
        if (e.northEast.lat === 90.0) {
            em.northEast.lat = ENLARGE_MERCATOR_LAT;
        }
        if (e.northEast.lon === 180.0) {
            em.northEast.lon = ENLARGE_MERCATOR_LON;
        }
        if (e.southWest.lat === -90.0) {
            em.southWest.lat = -ENLARGE_MERCATOR_LAT;
        }
        if (e.southWest.lon === -180.0) {
            em.southWest.lon = -ENLARGE_MERCATOR_LON;
        }

        // WHY!???
        // if (e.northEast.lat >= mercator.MAX_LAT) {
        //     e.northEast.lat = mercator.MAX_LAT;
        // }
        //
        // if (e.northEast.lat <= mercator.MIN_LAT) {
        //     e.northEast.lat = mercator.MIN_LAT;
        // }
    }
}

const XYZ_EVENTS: XYZEventsList = [
    /**
     * Triggered when current tile image has loaded before rendering.
     * @event #load
     */
    "load",

    /**
     * Triggered when all tiles have loaded or loading has stopped.
     * @event #loadend
     */
    "loadend"
];