Source: modules/config/BaseLayer.js

/**
 * @module config/BaseLayer.js
 * @name BaseLayer
 * @copyright 2023 3Liz
 * @author DHONT René-Luc
 * @license MPL-2.0
 */

import { ValidationError } from './../Errors.js';
import { BaseObjectConfig } from './BaseObject.js';
import { convertBoolean } from './../utils/Converters.js';
import { createEnum } from './../utils/Enums.js';
import { AttributionConfig } from './Attribution.js';
import { LayerConfig, LayersConfig } from './Layer.js';
import { LayerTreeGroupConfig } from './LayerTree.js';

/**
 * Enum for base layer types
 * @readonly
 * @enum {string}
 * @property {string} Empty  - The base layer type for project background color
 * @property {string} XYZ    - The base layer type for xyz layers
 * @property {string} Bing   - The base layer type for Bing layers
 * @property {string} WMTS   - The base layer type for Web Map Tile Service layers
 * @property {string} Lizmap - The base layer type for Lizmap layers
 */
export const BaseLayerTypes = createEnum({
    'Empty': 'empty',
    'XYZ': 'xyz',
    'Bing': 'bing',
    'WMTS': 'wmts',
    'WMS': 'wms',
    'Lizmap': 'lizmap',
    'Google': 'google',
});

/**
 * Class representing a base layer config
 * @class
 * @name BaseLayerConfig
 * @augments BaseObjectConfig
 */
export class BaseLayerConfig extends BaseObjectConfig {
    /**
     * Create a base layer config instance based on a config object
     * @param {string}      name                                                           - the base layer name
     * @param {object}      cfg                                                            - the base layer lizmap config object
     * @param {string}      cfg.title                                                      - the base layer title
     * @param {LayerConfig} [cfg.layerConfig]                                              - the base layer Lizmap layer config
     * @param {string}      [cfg.key]                                                      - the base layer key
     * @param {object}      [cfg.attribution]                                              - the base layer attribution config object
     * @param {object}      [requiredProperties]               - the required properties definition
     * @param {object}      [optionalProperties] - the optional properties definition
     */
    constructor(name, cfg, requiredProperties = { 'title': { type: 'string' } }, optionalProperties = { 'key': { type: 'string', nullable: true } }) {

        if (!requiredProperties.hasOwnProperty('title')) {
            requiredProperties['title'] = { type: 'string' };
        }

        if (!optionalProperties.hasOwnProperty('key')) {
            optionalProperties['key'] = { type: 'string', nullable: true };
        }

        super(cfg, requiredProperties, optionalProperties);
        this._type = BaseLayerTypes.Lizmap;

        this._name = name;

        this._hasLayerConfig = false;
        this._layerConfig = null;
        if (cfg.hasOwnProperty('layerConfig')
            && cfg.layerConfig instanceof LayerConfig) {
            this._layerConfig = cfg.layerConfig;
            this._hasLayerConfig = true;
        }

        this._hasAttribution = false;
        this._attribution = null;
        if (cfg.hasOwnProperty('attribution')) {
            if (cfg['attribution'] instanceof AttributionConfig) {
                this._attribution = cfg['attribution'];
                this._hasAttribution = true;
            } else if (Object.getOwnPropertyNames(cfg['attribution']).length != 0) {
                this._attribution = new AttributionConfig(cfg['attribution']);
                this._hasAttribution = true;
            }
        }
    }

    /**
     * The base layer type
     * @see BaseLayerTypes
     * @type {string}
     */
    get type() {
        return this._type;
    }

    /**
     * The base layer name
     * @type {string}
     */
    get name() {
        return this._name;
    }

    /**
     * The base layer title
     * @type {string}
     */
    get title() {
        return this._title;
    }

    /**
     * A Lizmap layer config is associated with this base layer
     * @type {boolean}
     */
    get hasLayerConfig() {
        return this._hasLayerConfig;
    }
    /**
     * The Lizmap layer config associated with this base layer
     * @type {?LayerConfig}
     */
    get layerConfig() {
        return this._layerConfig;
    }

    /**
     * The base layer key is defined
     * @type {boolean}
     */
    get hasKey() {
        return (this._key != null && typeof this._key == 'string' && this._key != '');
    }

    /**
     * The base layer key
     * @type {?string}
     */
    get key() {
        if (this.hasKey)
            return this._key;
        return null;
    }

    /**
     * Attribution is defined
     * @type {boolean}
     */
    get hasAttribution() {
        return this._hasAttribution;
    }
    /**
     * Attribution
     * @type {?AttributionConfig}
     */
    get attribution() {
        return this._attribution;
    }
}

/**
 * Class representing an empty base layer config
 * @class
 * @augments BaseLayerConfig
 */
export class EmptyBaseLayerConfig extends BaseLayerConfig {
    /**
     * Create an empty base layer config based on a config object (it can be empty)
     * @param {string} name - the base layer name
     * @param {object} cfg - an object for empty base layer
     */
    constructor(name, cfg) {
        if (!cfg || typeof cfg !== "object") {
            throw new ValidationError('The cfg parameter is not an Object!');
        }
        const emptyCfg = Object.assign({
            title: name
        }, cfg);
        const emptyProperties = {
            'title': { type: 'string' }
        };
        super(name, emptyCfg, emptyProperties, {});
        this._type = BaseLayerTypes.Empty;
    }
}

const xyzProperties = {
    'title': { type: 'string' },
    'url': { type: 'string' },
    'crs': { type: 'string' }
}

const xyzOptionalProperties = {
    'zmin': { type: 'number', default: 0 },
    'zmax': { type: 'number', default: 20 },
    'key': { type: 'string', nullable: true }
}

/**
 * Class representing an XYZ base layer config
 * @class
 * @augments BaseLayerConfig
 */
export class XyzBaseLayerConfig extends BaseLayerConfig {
    /**
     * Create an XYZ base layer config based on a config object
     * @param {string} name              - the base layer name
     * @param {object} cfg               - the lizmap config object for XYZ base layer
     * @param {string} cfg.title         - the base layer title
     * @param {string} cfg.url           - the base layer url
     * @param {string} cfg.crs           - the base layer crs
     * @param {number} [cfg.zmin]        - the base layer zmin
     * @param {number} [cfg.zmax]        - the base layer zmax
     * @param {string} [cfg.key]         - the base layer key
     * @param {object} [cfg.attribution] - the base layer attribution config object
     */
    constructor(name, cfg) {
        if (!cfg || typeof cfg !== "object") {
            throw new ValidationError('The cfg parameter is not an Object!');
        }

        if (Object.getOwnPropertyNames(cfg).length == 0) {
            throw new ValidationError('The cfg parameter is empty!');
        }

        super(name, cfg, xyzProperties, xyzOptionalProperties);
        this._type = BaseLayerTypes.XYZ;
    }

    /**
     * The base layer url
     * @type {string}
     */
    get url() {
        return this._url;
    }

    /**
     * The base layer zmin
     * @type {number}
     */
    get zmin() {
        return this._zmin;
    }

    /**
     * The base layer zmax
     * @type {number}
     */
    get zmax() {
        return this._zmax;
    }

    /**
     * The base layer crs
     * @type {string}
     */
    get crs() {
        return this._crs;
    }
}

const bingProperties = {
    'title': { type: 'string' },
    'imagerySet': { type: 'string' }
}

const bingOptionalProperties = {
    'key': { type: 'string', nullable: true }
}

/**
 * Class representing a BING base layer config
 * @class
 * @augments BaseLayerConfig
 */
export class BingBaseLayerConfig extends BaseLayerConfig {
    /**
     * Create a BING base layer config based on a config object
     * @param {string} name           - the base layer name
     * @param {object} cfg            - the lizmap config object for BING base layer
     * @param {string} cfg.title      - the base layer title
     * @param {string} cfg.imagerySet - the base layer imagerySet
     * @param {string} [cfg.key]      - the base layer key
     */
    constructor(name, cfg) {
        if (!cfg || typeof cfg !== "object") {
            throw new ValidationError('The cfg parameter is not an Object!');
        }

        if (Object.getOwnPropertyNames(cfg).length == 0) {
            throw new ValidationError('The cfg parameter is empty!');
        }

        super(name, cfg, bingProperties, bingOptionalProperties)
        this._type = BaseLayerTypes.Bing;
    }

    /**
     * The bing imagerySet
     * @type {string}
     */
    get imagerySet() {
        return this._imagerySet;
    }

}

const googleProperties = {
    'title': { type: 'string' },
    'mapType': { type: 'string' }
}

const googleOptionalProperties = {
    'key': { type: 'string', nullable: true }
}

/**
 * Class representing a Google base layer config
 * @class
 * @augments BaseLayerConfig
 */
export class GoogleBaseLayerConfig extends BaseLayerConfig {
    /**
     * Create a GOOGLE base layer config based on a config object
     * @param {string} name           - the base layer name
     * @param {object} cfg            - the lizmap config object for GOOGLE base layer
     * @param {string} cfg.title      - the base layer title
     * @param {string} cfg.mapType    - the base layer mapType
     * @param {string} [cfg.key]      - the base layer key
     */
    constructor(name, cfg) {
        if (!cfg || typeof cfg !== "object") {
            throw new ValidationError('The cfg parameter is not an Object!');
        }

        if (Object.getOwnPropertyNames(cfg).length == 0) {
            throw new ValidationError('The cfg parameter is empty!');
        }

        super(name, cfg, googleProperties, googleOptionalProperties)
        this._type = BaseLayerTypes.Google;
    }

    /**
     * The Google mapType
     * @type {string}
     */
    get mapType() {
        return this._mapType;
    }

}

const wmtsProperties = {
    'title': { type: 'string' },
    'url': { type: 'string' },
    'layers': { type: 'string' },
    'format': { type: 'string' },
    'styles': { type: 'string' },
    'tileMatrixSet': { type: 'string' },
    'crs': { type: 'string' }
}

const wmtsOptionalProperties = {
    'numZoomLevels': { type: 'number', default: 19   },
    'key': { type: 'string', nullable: true }
}

/**
 * Class representing a WMTS base layer config
 * @class
 * @augments BaseLayerConfig
 */
export class WmtsBaseLayerConfig extends BaseLayerConfig {
    /**
     * Create a WMTS base layer config based on a config object
     * @param {string} name                - the base layer name
     * @param {object} cfg                 - the lizmap config object for WMTS base layer
     * @param {string} cfg.title           - the base layer title
     * @param {string} cfg.url             - the base layer url
     * @param {string} cfg.layers          - the base layer layer
     * @param {string} cfg.format          - the base layer format
     * @param {string} cfg.styles          - the base layer style
     * @param {string} cfg.tileMatrixSet   - the base layer matrixSet
     * @param {string} cfg.crs             - the base layer crs
     * @param {number} [cfg.numZoomLevels] - the base layer numZoomLevels
     * @param {string} [cfg.key]           - the base layer key
     * @param {object} [cfg.attribution]   - the base layer attribution config object
     */
    constructor(name, cfg) {
        if (!cfg || typeof cfg !== "object") {
            throw new ValidationError('The cfg parameter is not an Object!');
        }

        if (Object.getOwnPropertyNames(cfg).length == 0) {
            throw new ValidationError('The cfg parameter is empty!');
        }

        super(name, cfg, wmtsProperties, wmtsOptionalProperties);
        this._type = BaseLayerTypes.WMTS;

        // Remove unnecessary parameters
        let wmtsUrl = new URL(this._url);
        let keysToRemove = []
        for (const [key, ] of wmtsUrl.searchParams) {
            if (key.toLowerCase() == 'service'
                || key.toLowerCase() == 'version'
                || key.toLowerCase() == 'request') {
                keysToRemove.push(key);
            }
        }
        if (keysToRemove.length != 0) {
            for (const key of keysToRemove) {
                wmtsUrl.searchParams.delete(key);
            }
            this._url = wmtsUrl.toString();
        }
    }

    /**
     * The base layer url
     * @type {string}
     */
    get url() {
        return this._url;
    }

    /**
     * The base layer wmts layer
     * @type {string}
     */
    get layer() {
        return decodeURIComponent(this._layers);
    }

    /**
     * The base layer wmts format
     * @type {string}
     */
    get format() {
        return decodeURIComponent(this._format);
    }

    /**
     * The base layer wmts style
     * @type {string}
     */
    get style() {
        return decodeURIComponent(this._styles);
    }

    /**
     * The base layer matrixSet
     * @type {string}
     */
    get matrixSet() {
        return decodeURIComponent(this._tileMatrixSet);
    }

    /**
     * The base layer crs
     * @type {string}
     */
    get crs() {
        return this._crs;
    }

    /**
     * The base layer numZoomLevels
     * @type {number}
     */
    get numZoomLevels() {
        return this._numZoomLevels;
    }

}

const wmsProperties = {
    'title': { type: 'string' },
    'url': { type: 'string' },
    'layers': { type: 'string' },
    'format': { type: 'string' },
    'styles': { type: 'string' },
    'crs': { type: 'string' }
}

const wmsOptionalProperties = {
    'key': { type: 'string', nullable: true }
}

/**
 * Class representing a WMS base layer config
 * @class
 * @augments BaseLayerConfig
 */
export class WmsBaseLayerConfig extends BaseLayerConfig {
    /**
     * Create a WMS base layer config based on a config object
     * @param {string} name                - the base layer name
     * @param {object} cfg                 - the lizmap config object for WMTS base layer
     * @param {string} cfg.title           - the base layer title
     * @param {string} cfg.url             - the base layer url
     * @param {string} cfg.layers          - the base layer layer
     * @param {string} cfg.format          - the base layer format
     * @param {string} cfg.styles          - the base layer style
     * @param {string} cfg.crs             - the base layer crs
     * @param {string} [cfg.key]           - the base layer key
     */
    constructor(name, cfg) {
        if (!cfg || typeof cfg !== "object") {
            throw new ValidationError('The cfg parameter is not an Object!');
        }

        if (Object.getOwnPropertyNames(cfg).length == 0) {
            throw new ValidationError('The cfg parameter is empty!');
        }

        super(name, cfg, wmsProperties, wmsOptionalProperties);
        this._type = BaseLayerTypes.WMS;

        // Remove unnecessary parameters
        let wmtsUrl = new URL(this._url);
        let keysToRemove = []
        for (const [key, ] of wmtsUrl.searchParams) {
            if (key.toLowerCase() == 'service'
                || key.toLowerCase() == 'version'
                || key.toLowerCase() == 'request') {
                keysToRemove.push(key);
            }
        }
        if (keysToRemove.length != 0) {
            for (const key of keysToRemove) {
                wmtsUrl.searchParams.delete(key);
            }
            this._url = wmtsUrl.toString();
        }
    }

    /**
     * The base layer url
     * @type {string}
     */
    get url() {
        return this._url;
    }

    /**
     * The base layer wms layers
     * @type {string}
     */
    get layers() {
        return decodeURIComponent(this._layers);
    }

    /**
     * The base layer wms format
     * @type {string}
     */
    get format() {
        return decodeURIComponent(this._format);
    }

    /**
     * The base layer wms styles
     * @type {string}
     */
    get styles() {
        return decodeURIComponent(this._styles);
    }

    /**
     * The base layer crs
     * @type {string}
     */
    get crs() {
        return this._crs;
    }
}

/**
 * The default complete lizmap config object for base layers
 * It will be used to translate lizmap options config to base layers config
 * @constant
 * @type {object}
 * @private
 */
const defaultCompleteBaseLayersCfg = {
    "empty": {},
    "project-background-color": {},
    "osm-mapnik": {
        "type": "xyz",
        "title": "OpenStreetMap",
        "url": "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
        "zmin": 0,
        "zmax": 19,
        "crs": "EPSG:3857",
        "attribution": {
            "title": "© OpenStreetMap contributors, CC-BY-SA",
            "url": "https://www.openstreetmap.org/copyright"
        }
    },
    "osm-stamen-toner": {
        "type": "xyz",
        "title": "OSM Stamen Toner",
        "url": "https://stamen-tiles-{a-d}.a.ssl.fastly.net/toner-lite/{z}/{x}/{y}.png",
        "zmin": 0,
        "zmax": 20,
        "crs": "EPSG:3857",
        "attribution": {
            "title": "Map tiles by Stamen Design, under CC BY 3.0. Data by OpenStreetMap, under ODbL",
            "url": "https://maps.stamen.com/"
        }
    },
    "open-topo-map": {
        "type": "xyz",
        "title": "OpenTopoMap",
        "url": "https://{a-c}.tile.opentopomap.org/{z}/{x}/{y}.png",
        "zmin": 0,
        "zmax": 18,
        "crs": "EPSG:3857",
        "attribution": {
            "title": "Kartendaten: © OpenStreetMap-Mitwirkende, SRTM | Kartendarstellung: © OpenTopoMap (CC-BY-SA)",
            "url": "https://www.openstreetmap.org/copyright"
        }
    },
    "osm-cyclemap": {
        "type": "xyz",
        "title": "OSM CycleMap",
        "url": "https://{a-c}.tile.thunderforest.com/cycle/{z}/{x}/{y}.png?apikey={key}",
        "key": "",
        "zmin": 0,
        "zmax": 18,
        "crs": "EPSG:3857",
        "attribution": {
            "title": "Thunderforest",
            "url": "https://www.thunderforest.com/"
        }
    },
    "google-street": {
        "type": "xyz",
        "title": "Google Streets",
        "url": "https://mt1.google.com/vt/lyrs=m&x={x}&y={y}&z={z}",
        "zmin": 0,
        "zmax": 20,
        "crs": "EPSG:3857",
        "attribution": {
            "title": "Map data ©2019 Google",
            "url": "https://about.google/brand-resource-center/products-and-services/geo-guidelines/#required-attribution"
        }
    },
    "google-satellite": {
        "type": "xyz",
        "title": "Google Satellite",
        "url": "https://mt1.google.com/vt/lyrs=s&x={x}&y={y}&z={z}",
        "zmin": 0,
        "zmax": 20,
        "crs": "EPSG:3857",
        "attribution": {
            "title": "Map data ©2019 Google",
            "url": "https://about.google/brand-resource-center/products-and-services/geo-guidelines/#required-attribution"
        }
    },
    "google-hybrid": {
        "type": "xyz",
        "title": "Google Hybrid",
        "url": "https://mt1.google.com/vt/lyrs=y&x={x}&y={y}&z={z}",
        "zmin": 0,
        "zmax": 20,
        "crs": "EPSG:3857",
        "attribution": {
            "title": "Map data ©2019 Google",
            "url": "https://about.google/brand-resource-center/products-and-services/geo-guidelines/#required-attribution"
        }
    },
    "google-terrain": {
        "type": "xyz",
        "title": "Google Terrain",
        "url": "https://mt1.google.com/vt/lyrs=p&x={x}&y={y}&z={z}",
        "zmin": 0,
        "zmax": 20,
        "crs": "EPSG:3857",
        "attribution": {
            "title": "Map data ©2019 Google",
            "url": "https://about.google/brand-resource-center/products-and-services/geo-guidelines/#required-attribution"
        }
    },
    "bing-road": {
        "type": "bing",
        "title": "Bing Streets",
        "imagerySet": "RoadOnDemand",
        "key": "",
    },
    "bing-aerial": {
        "type": "bing",
        "title": "Bing Satellite",
        "imagerySet": "Aerial",
        "key": "",
    },
    "bing-hybrid": {
        "type": "bing",
        "title": "Bing Hybrid",
        "imagerySet": "AerialWithLabelsOnDemand",
        "key": "",
    },
    "ign-plan": {
        "type": "wmts",
        "title": "IGN Plan",
        "url": "https://data.geopf.fr/wmts?",
        "layers": "GEOGRAPHICALGRIDSYSTEMS.PLANIGNV2",
        "format": "image/png",
        "styles": "normal",
        "tileMatrixSet": "PM",
        "crs": "EPSG:3857",
        "numZoomLevels": 19,
        "attribution": {
            "title": "Institut national de l'information géographique et forestière",
            "url": "https://www.ign.fr/"
        }
    },
    "ign-photo": {
        "type": "wmts",
        "title": "IGN Orthophoto",
        "url": "https://data.geopf.fr/wmts?",
        "layers": "ORTHOIMAGERY.ORTHOPHOTOS",
        "format": "image/jpeg",
        "styles": "normal",
        "tileMatrixSet": "PM",
        "crs": "EPSG:3857",
        "numZoomLevels": 19,
        "attribution": {
            "title": "Institut national de l'information géographique et forestière",
            "url": "https://www.ign.fr/"
        }
    },
    "ign-scan": {
        "type": "wmts",
        "title": "IGN Scans",
        "url": "https://data.geopf.fr/private/wmts/?apikey={key}&",
        "key": "",
        "layers": "GEOGRAPHICALGRIDSYSTEMS.MAPS",
        "format": "image/jpeg",
        "styles": "normal",
        "tileMatrixSet": "PM",
        "crs": "EPSG:3857",
        "numZoomLevels": 18,
        "attribution": {
            "title": "Institut national de l'information géographique et forestière",
            "url": "https://www.ign.fr/"
        }
    },
    "ign-cadastral": {
        "type": "wmts",
        "title": "IGN Cadastre",
        "url": "https://data.geopf.fr/wmts?",
        "layers": "CADASTRALPARCELS.PARCELLAIRE_EXPRESS",
        "format": "image/png",
        "styles": "normal",
        "tileMatrixSet": "PM",
        "crs": "EPSG:3857",
        "numZoomLevels": 19,
        "attribution": {
            "title": "Institut national de l'information géographique et forestière",
            "url": "https://www.ign.fr/"
        }
    },

};

/**
 * The QuickMapServices external layers object configuration
 * It will be used to define base layers config based on the type of external layer
 * @constant
 * @type {object}
 * @see https://plugins.qgis.org/plugins/quick_map_services/
 * @private
 */
const QMSExternalLayer = {
    "qms-bing-roads": {
        "type": "bing",
        "title": "Bing Streets",
        "imagerySet": "RoadOnDemand",
        "key": "",
    },
    "qms-bing-satellite": {
        "type": "bing",
        "title": "Bing Satellite",
        "imagerySet": "Aerial",
        "key": "",
    },
    "google-streets": {
        "type" :"google",
        "title": "Google Streets",
        "mapType": "roadmap",
        "key":""
    },
    "google-satellite": {
        "type" :"google",
        "title": "Google Satellite",
        "mapType": "satellite",
        "key":""
    }
}

/**
 * Class representing a base layers config
 * @class
 */
export class BaseLayersConfig {
    /**
     * Create a base layers config based on a config object, the options config object and the layers config
     * @param {object} cfg                                 - the lizmap config object for base layers
     * @param {object} options                             - the lizmap config object for options
     * @param {LayersConfig} layers                        - the lizmap layers config
     * @param {LayerTreeGroupConfig} [baseLayersTreeGroup] - the layer tree group config which contains base layers
     * @param {LayerTreeGroupConfig} [hiddenTreeGroup]     - the layer tree group config which contains hidden layers and in old config base layers
     */
    constructor(cfg, options, layers, baseLayersTreeGroup, hiddenTreeGroup) {
        if (!cfg || typeof cfg !== "object") {
            throw new ValidationError('The cfg parameter is not an Object!');
        }
        if (!options || typeof options !== "object") {
            throw new ValidationError('The options parameter is not an Object!');
        }
        if (!layers || typeof layers !== "object" || !(layers instanceof LayersConfig)) {
            throw new ValidationError('The layers parameter is not a LayersConfig instance!');
        }

        // Clone config to extend it with options and layers
        let extendedCfg = structuredClone(cfg);

        // Converting options properties to base layers config
        const optionsProperties = {
            emptyBaselayer: { name: 'empty' },
            osmMapnik: { name: 'osm-mapnik' },
            osmStamenToner: { name: 'osm-stamen-toner' },
            openTopoMap: { name: 'open-topo-map' },
            osmCyclemap: { name: 'osm-cyclemap', key: 'OCMKey' },
            googleStreets: { name: 'google-street' },
            googleSatellite: { name: 'google-satellite' },
            googleHybrid: { name: 'google-hybrid' },
            googleTerrain: { name: 'google-terrain' },
            bingStreets: { name: 'bing-road', key: 'bingKey' },
            bingSatellite: { name: 'bing-aerial', key: 'bingKey' },
            bingHybrid: { name: 'bing-hybrid', key: 'bingKey' },
            ignTerrain: { name: 'ign-scan', key: 'ignKey' },
            ignStreets: { name: 'ign-plan' },
            ignSatellite: { name: 'ign-photo' },
            ignCadastral: { name: 'ign-cadastral' }
        };
        for (const key in optionsProperties) {
            if (options.hasOwnProperty(key) && convertBoolean(options[key])) {
                const opt = optionsProperties[key];
                extendedCfg[opt.name] = structuredClone(defaultCompleteBaseLayersCfg[opt.name]);
                if (opt.hasOwnProperty('key') && options.hasOwnProperty(opt['key'])) {
                    extendedCfg[opt.name]['key'] = options[opt['key']];
                }
            }
        }

        // Add base layers config from hidden tree group
        if (hiddenTreeGroup && typeof hiddenTreeGroup.getChildren == "function") {
            for (const layerTreeItem of hiddenTreeGroup.getChildren()) {
                if ( !extendedCfg.hasOwnProperty(layerTreeItem.name) ) {
                    continue;
                }
                extendedCfg[layerTreeItem.name].layerConfig = layerTreeItem.layerConfig;
            }
        }

        // Add base layers from tree and collect names
        let names = [];
        if (baseLayersTreeGroup) {
            for (const layerTreeItem of baseLayersTreeGroup.getChildren()) {
                if ( !extendedCfg.hasOwnProperty(layerTreeItem.name) ) {
                    if ( defaultCompleteBaseLayersCfg.hasOwnProperty(layerTreeItem.name) ) {
                        // The name is known has a default base layer
                        extendedCfg[layerTreeItem.name] = structuredClone(defaultCompleteBaseLayersCfg[layerTreeItem.name]);
                    } else if ( layerTreeItem.layerConfig.externalWmsToggle ){
                        // The layer config has external access parameters
                        if (layerTreeItem.layerConfig.externalAccess.hasOwnProperty('type')) {
                            // search for QuickMapSevice plugin layers.
                            // The layers identification is based on the url property of the externalAccess object
                            const externalUrl = layerTreeItem.layerConfig.externalAccess.url;
                            if (externalUrl && externalUrl.includes('virtualearth.net') && options["bingKey"]) {
                                // Bing maps
                                // detect if the url is for roads or satellite
                                if (externalUrl.includes('dynamic')) {
                                    // roads
                                    extendedCfg[layerTreeItem.name] = structuredClone(QMSExternalLayer["qms-bing-roads"])
                                } else {
                                    // fallback on satellite map
                                    extendedCfg[layerTreeItem.name] = structuredClone(QMSExternalLayer["qms-bing-satellite"])
                                }
                                // add the apikey to the configuration
                                Object.assign(extendedCfg[layerTreeItem.name],{key:options["bingKey"]})
                            } else if (externalUrl && externalUrl.includes('google.com') && options["googleKey"]){
                                if (externalUrl.includes('lyrs=m')) {
                                    // roads
                                    extendedCfg[layerTreeItem.name] = structuredClone(QMSExternalLayer["google-streets"])
                                } else if (externalUrl.includes('lyrs=s')){
                                    // fallback on satellite map
                                    extendedCfg[layerTreeItem.name] = structuredClone(QMSExternalLayer["google-satellite"])
                                } else {
                                    extendedCfg[layerTreeItem.name] = structuredClone(QMSExternalLayer["google-streets"])
                                }
                                // add the apikey to the configuration
                                Object.assign(extendedCfg[layerTreeItem.name],{key:options["googleKey"]})
                            }
                            else {
                                // layer could be converted to XYZ or WMTS background layers
                                extendedCfg[layerTreeItem.name] = structuredClone(layerTreeItem.layerConfig.externalAccess);
                            }
                        } else {
                            extendedCfg[layerTreeItem.name] = Object.assign(
                                structuredClone(layerTreeItem.layerConfig.externalAccess),
                                {type: BaseLayerTypes.WMS}
                            );
                        }
                    } else {
                        // If the tree item is a layer associated to QGIS group do not keep
                        // we already keep empty or project-background-color QGIS group
                        if (layerTreeItem.type === 'layer' && layerTreeItem.layerConfig.type === 'group') {
                            continue;
                        }
                        // If the tree item is a group without any QGIS layer do not keep
                        if (layerTreeItem.type === 'group'
                            && layerTreeItem.findTreeLayerConfigs().filter(l => l.layerConfig.type === 'layer').length == 0) {
                            continue;
                        }
                        // It is a lizmap layer
                        extendedCfg[layerTreeItem.name] = {
                            "type": "lizmap",
                        }
                    }
                }
                // Override title and set layer config
                extendedCfg[layerTreeItem.name].title = layerTreeItem.wmsTitle;
                extendedCfg[layerTreeItem.name].layerConfig = layerTreeItem.layerConfig;
                const wmsAttribution = layerTreeItem.wmsAttribution;
                if (wmsAttribution != null) {
                    extendedCfg[layerTreeItem.name].attribution = wmsAttribution;
                }
                names.push(layerTreeItem.name);
            }
        }

        // Add layers from config
        for (const layerCfg of layers.getLayerConfigs()) {
            if (!layerCfg.baseLayer) {
                continue;
            }
            if (!extendedCfg.hasOwnProperty(layerCfg.name) ) {
                if ( defaultCompleteBaseLayersCfg.hasOwnProperty(layerCfg.name) ) {
                    extendedCfg[layerCfg.name] = structuredClone(defaultCompleteBaseLayersCfg[layerCfg.name]);
                    // Override title
                    extendedCfg[layerCfg.name].title = layerCfg.title;
                } else if ( layerCfg.externalWmsToggle ){
                    // The layer config has external access parameters
                    if (layerCfg.externalAccess.hasOwnProperty('type')) {
                        // layer could be converted to XYZ or WMTS background layers
                        extendedCfg[layerCfg.name] = structuredClone(layerCfg.externalAccess);
                    } else {
                        extendedCfg[layerCfg.name] = Object.assign(
                            structuredClone(layerCfg.externalAccess),
                            {type: BaseLayerTypes.WMS}
                        );
                    }
                    // set title
                    extendedCfg[layerCfg.name].title = layerCfg.title;
                } else {
                    extendedCfg[layerCfg.name] = {
                        "type": BaseLayerTypes.Lizmap,
                        "title": layerCfg.title,
                    }
                }
            }
            // Set layer config
            extendedCfg[layerCfg.name].layerConfig = layerCfg;
        }

        // Add base layer as project default background color
        // Get provided default background color index from options
        const default_background_color_index = options.hasOwnProperty('default_background_color_index') ? options.default_background_color_index : -1;
        if (isNaN(default_background_color_index)) {
            throw new ValidationError('The default_background_color_index parameter is not a number!');
        }
        // Apply default background color if it is not already defined
        if (names.indexOf('empty') == -1
            && names.indexOf('project-background-color') == -1) {
            let background_color_index = default_background_color_index;
            // Calculate background color index if
            // * it is not provided by options
            // * baselayers and project-background-color have lizmap layer config
            // * baselayers group is before project-background-color
            if (background_color_index == -1
                && layers.layerNames.indexOf('baselayers') !== -1
                && layers.layerNames.indexOf('project-background-color') !== -1
                && layers.layerNames.indexOf('baselayers') < layers.layerNames.indexOf('project-background-color')) {
                const baselayersGroupIndex = layers.layerNames.indexOf('baselayers');
                const global_background_color_index = layers.layerNames.indexOf('project-background-color');
                // The background color index will be the number of layers before project-background-color in baselayers group
                for (const [i, baselayerCfg] of layers.layerConfigs.entries()) {
                    if (i <= baselayersGroupIndex) {
                        continue;
                    }
                    if (i > global_background_color_index) {
                        break;
                    }
                    if (baselayerCfg.type != 'group') {
                        background_color_index += 1
                    }
                    if (baselayerCfg.name == 'project-background-color') {
                        background_color_index += 1
                    }
                }
            }
            // Add base layer as project default background color
            if (background_color_index !== -1) {
                if (background_color_index < names.length) {
                    names.splice(background_color_index, 0, 'project-background-color');
                } else {
                    names.push('project-background-color');
                }
            }
        }

        // Define startup base layer based on names from tree
        this._startupBaselayer = null;
        if (names.length != 0) {
            this._startupBaselayer = names[0];
        }

        // Add names from keys
        for (const key in extendedCfg) {
            if (names.indexOf(key) == -1) {
                names.push(key);
            }
        }

        this._names = [];
        this._configs = [];
        for (const key of names) {
            if (key == 'empty' || key == 'project-background-color') {
                if (extendedCfg.hasOwnProperty(key)) {
                    this._configs.push(new EmptyBaseLayerConfig(key, extendedCfg[key]));
                } else {
                    this._configs.push(new EmptyBaseLayerConfig(key, {}));
                }
                this._names.push(key);
                continue;
            }
            const blCfg = extendedCfg[key];
            if (!blCfg.hasOwnProperty('type')) {
                throw new ValidationError('No `type` in the baseLayer cfg object!');
            }
            switch (blCfg.type) {
                case BaseLayerTypes.XYZ:
                    this._configs.push(new XyzBaseLayerConfig(key, blCfg));
                    this._names.push(key);
                    break;
                case BaseLayerTypes.Bing:
                    this._configs.push(new BingBaseLayerConfig(key, blCfg));
                    this._names.push(key);
                    break;
                case BaseLayerTypes.Google:
                    this._configs.push(new GoogleBaseLayerConfig(key, blCfg));
                    this._names.push(key);
                    break;
                case BaseLayerTypes.WMTS:
                    this._configs.push(new WmtsBaseLayerConfig(key, blCfg));
                    this._names.push(key);
                    break;
                case BaseLayerTypes.WMS:
                    this._configs.push(new WmsBaseLayerConfig(key, blCfg));
                    this._names.push(key);
                    break;
                default:
                    this._configs.push(new BaseLayerConfig(key, blCfg));
                    this._names.push(key);
                    break;
            }
        }

        if (this._startupBaselayer == null) {
            if (options.hasOwnProperty('startupBaselayer')) {
                let startupBlName = options.startupBaselayer
                if (optionsProperties.hasOwnProperty(startupBlName)) {
                    startupBlName = optionsProperties[startupBlName].name;
                }

                // If there is a single baselayer defined, it is the startup one
                if (this._names.length === 1) {
                    this._startupBaselayer = this._names[0];
                } else if (this._names.indexOf(startupBlName) == -1) {
                    this._startupBaselayer = null;
                } else {
                    this._startupBaselayer = startupBlName;
                }
            } else {
                this._startupBaselayer = null;
            }
        }
    }

    /**
     *  The startup base layer name
     * @type {?string}
     */
    get startupBaselayerName() {
        return this._startupBaselayer;
    }

    /**
     * The copy of base layer names
     * @type {string[]}
     */
    get baseLayerNames() {
        return [...this._names];
    }

    /**
     * The copy of base layer configs
     * @type {BaseLayerConfig[]}
     */
    get baseLayerConfigs() {
        return [...this._configs];
    }

    /**
     * Iterate through base layer names
     * @generator
     * @yields {string} The next base layer name
     */
    *getBaseLayerNames() {
        for (const name of this._names) {
            yield name;
        }
    }

    /**
     * Iterate through base layer configs
     * @generator
     * @yields {BaseLayerConfig} The next base layer config
     */
    *getBaseLayerConfigs() {
        for (const config of this._configs) {
            yield config;
        }
    }

    /**
     * Get a base layer config by base layer name
     * @param {string} name the base layer name
     * @returns {BaseLayerConfig} The base layer config associated to the name
     * @throws {RangeError|Error} The base layer name is unknown or the config has been corrupted
     */
    getBaseLayerConfigByBaseLayerName(name) {
        const idx = this._names.indexOf(name);
        if (idx == -1) {
            throw new RangeError('The base layer name `' + name + '` is unknown!');
        }

        const cfg = this._configs[idx];
        if (cfg.name != name) {
            throw 'The config has been corrupted!'
        }

        return cfg;
    }
}