layer_XYZ.ts

import * as mercator from "../mercator";
import {Layer, ILayerParams, LayerEventsList} from "./Layer";
import {RENDERING} from "../quadTree/quadTree";
import {Segment} from "../segment/Segment";
import {stringTemplate} from "../utils/shared";
import {EventsHandler} from "../Events";
import {Material} from "./Material";
import {NumberArray4} from "../math/Vec4";
import {IResponse} from "../utils/Loader";

export interface IXYZParams extends ILayerParams {
    url?: string;
    subdomains?: string[];
    minNativeZoom?: number;
    maxNativeZoom?: number;
    urlRewrite?: Function;
}

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 that displayed in the attribution area on the screen.
 * @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 gl filter. NEAREST, LINEAR, MIPMAP, ANISOTROPIC.
 * @param {Function} options.urlRewrite - Url rewrite function.
 *
 * @fires EventsHandler<XYZEventsList>#load
 * @fires EventsHandler<XYZEventsList>#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 Layer {

    public override events: XYZEventsType;

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

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

    /**
     * Minimal native zoom level when tiles are available.
     * @public
     * @type {number}
     */
    public minNativeZoom: number;

    /**
     * Maximal native zoom level when tiles are available.
     * @public
     * @type {number}
     */
    public maxNativeZoom: number;

    /**
     * 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;

    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;
    }

    /**
     * @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;// EPSG4326.id;// EPSG3857.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: {}
                    },
                    (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();
                            }
                        }
                    }
                    //,this.__id
                );
            } else {
                material.textureNotExists();
            }
        }
    }

    /**
     * Creates query url.
     * @protected
     * @virtual
     * @param {Segment} segment - Creates specific url for current segment.
     * @returns {string} - Returns 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;
    }

    public override applyMaterial(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;
    }

    public override clearMaterial(material: Material) {
        if (material.isReady && material.textureExists) {
            !material.texture!.default && material.segment.handler.gl!.deleteTexture(material.texture!);
            material.texture = null;
        }

        material.isReady = false;
        material.textureExists = false;
        material.isLoading = false;
    }

    /**
     * @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;
        }

        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"
];