WME GIS Layers

Adds Paraguay GIS layers in WME

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(У мене вже є менеджер скриптів, дайте мені встановити його!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

/* eslint-disable camelcase */
/* eslint-disable brace-style, curly, nonblock-statement-body-position, no-template-curly-in-string, func-names */
// ==UserScript==
// @name         WME GIS Layers
// @namespace    https://greatest.deepsurf.us/users/324334
// @version      2023.09.27.001-py028
// @description  Adds Paraguay GIS layers in WME
// @author       MapOMatic
// @match         *://*.waze.com/*editor*
// @exclude       *://*.waze.com/user/editor*
// @require      https://greatest.deepsurf.us/scripts/24851-wazewrap/code/WazeWrap.js
// @require      https://cdnjs.cloudflare.com/ajax/libs/Turf.js/4.7.3/turf.min.js
// @grant        GM_xmlhttpRequest
// @connect      greatest.deepsurf.us
// @grant        GM_info
// @license      GNU GPLv3
// @contributionURL https://github.com/WazeDev/Thank-The-Authors
// @connect      *
// @connect www.asuncion.gov.py
// @connect analisis.stp.gov.py
// @connect www.arcgis.com
// @connect services1.arcgis.com
// @connect services2.arcgis.com
// @connect services3.arcgis.com
// @connect services5.arcgis.com
// @connect services6.arcgis.com
// @connect services8.arcgis.com
// @connect services9.arcgis.com
// @connect geohidroinformatica.itaipu.gov.py
// @connect geobosques.pti.org.py
// @connect catastro.gov.py
// @connect geo1.skycop.com.py
// @connect sigcosiplan.unasursg.org
// @connect snmf.infona.gov.py
// @connect 190.52.167.121
// @connect sedac.ciesin.columbia.edu
// @connect 190.128.205.76
// @connect wwf-sight-maps.org
// @connect www.geosur.info
// @connect a.mapillary.com
// @connect geoshape.unasursg.org
// @connect geo-ide.carto.com
// @connect 201.217.59.143
// @connect pese.pti.org.py
// @connect geo.pti.org.py
// @connect www.mapadeasentamientos.org.py
// @connect gis-gfw.wri.org
// @connect opengeo.pol.una.py
// @connect gis.mic.gov.py
// @connect vigisalud.gov.py
// @connect mapaescolar.mec.gov.py
// @connect apps.mades.gov.py
// @connect www.mopc.gov.py
// ==/UserScript==

// This version is for Paraguay Only, modified by ancho85
/* global OpenLayers */
/* global W */
/* global WazeWrap */
/* global _ */
/* global turf */

(function main() {
    'use strict';

    // **************************************************************************************************************
    // IMPORTANT: Update this when releasing a new version of script that includes changes to the spreadsheet format
    //            that may cause old code to break.  This # should match the version listed in the spreadsheet
    //            i.e. update them at the same time.

    // const LAYER_DEF_VERSION = '2018.04.27.001';  // NOT ACTUALLY USED YET

    // **************************************************************************************************************
    // const UPDATE_MESSAGE = 'Bug fix due to WME update';
    // const UPDATE_MESSAGE = `<ul>${[
    //     'Added ability to shift layers. Right click a layer in the list to bring up the layer settings window.'
    // ].map(item => `<li>${item}</li>`).join('')}</ul><br>`;
    const GF_URL = 'https://greatest.deepsurf.us/en/scripts/388277-wme-paraguay-gis-layers';
    // Used in tooltips to tell people who to report issues to.  Update if a new author takes ownership of this script.
    const SCRIPT_AUTHOR = 'ancho85'; // MapOMatic is the original author, but he won't fix any Paraguay related issues
    // const LAYER_INFO_URL = 'https://spreadsheets.google.com/feeds/list/1cEG3CvXSCI4TOZyMQTI50SQGbVhJ48Xip-jjWg4blWw/o7gusx3/public/values?alt=json';
    const LAYER_DEF_SPREADSHEET_URL = 'https://sheets.googleapis.com/v4/spreadsheets/1aePOmux2IBxE_2CGPOequGnubr9g4hWr1wH_qAjcM24/values/layerDefs';
    const API_KEY = 'UVVsNllWTjVSSEJvYm5sQ05FdElNa3BqV1RBMFZtZHRSMDFRYm5Ca1ZURkZNRGRIYUVkbg==';
    const REQUEST_FORM_URL = 'https://docs.google.com/forms/d/e/1FAIpQLSfMhBxF0P6bn8dFfOoNTAF1LHBFXr5w9oXvzqsii_TfA-_Bmw/viewform?usp=pp_url&entry.831784226={username}';
    const DEC = s => atob(atob(s));
    const PRIVATE_LAYERS = { 'nc-henderson-sl-signs': ['the_cre8r', 'mapomatic'] }; // case sensitive -- use all lower case
    // const COUNTRIES = {
    //     'United States': {
    //         sheetId: '1cEG3CvXSCI4TOZyMQTI50SQGbVhJ48Xip-jjWg4blWw',
    //         sheetLayerRange: 'layerDefs'
    //     }
    // };
    const DEFAULT_STYLE = {
        fillColor: '#000',
        pointRadius: 4,
        label: '${label}',
        strokeColor: '#ffa500',
        strokeOpacity: '0.95',
        strokeWidth: 1.5,
        fontColor: '#ffc520',
        fontSize: '13',
        labelOutlineColor: 'black',
        labelOutlineWidth: 3
    };
    const LAYER_STYLES = {
        cities: {
            fillOpacity: 0.3,
            fillColor: '#f65',
            strokeColor: '#f65',
            fontColor: '#f62'
        },
        forests_parks: {
            fillOpacity: 0.4,
            fillColor: '#585',
            strokeColor: '#484',
            fontColor: '#8b8'
        },
        milemarkers: {
            strokeColor: '#fff',
            fontColor: '#fff',
            fontWeight: 'bold',
            fillOpacity: 0,
            labelYOffset: 10,
            pointRadius: 2,
            fontSize: 12
        },
        parcels: {
            fillOpacity: 0,
            fillColor: '#ffa500'
        },
        points: {
            strokeColor: '#000',
            fontColor: '#0ff',
            fillColor: '#0ff',
            labelYOffset: -10,
            labelAlign: 'ct'
        },
        post_offices: {
            strokeColor: '#000',
            fontColor: '#f84',
            fillColor: '#f84',
            fontWeight: 'bold',
            labelYOffset: -10,
            labelAlign: 'ct'
        },
        state_parcels: {
            fillOpacity: 0,
            strokeColor: '#e62',
            fillColor: '#e62',
            fontColor: '#e73'
        },
        state_points: {
            strokeColor: '#000',
            fontColor: '#3cf',
            fillColor: '#3cf',
            labelYOffset: -10,
            labelAlign: 'ct'
        },
        road_labels: {
            strokeOpacity: 0,
            fillOpacity: 0,
            fontColor: '#faf'
        },
        structures: {
            fillOpacity: 0,
            strokeColor: '#f7f',
            fontColor: '#f7f'
        },
        water: {
            fillOpacity: 1,
            strokeColor: '#13a1dd',
            fillColor: '#13a1dd',
            fontColor: '#13a1dd',
            fontWeight: 'bold'
        }
    };
    let ROAD_STYLE;
    function initRoadStyle() {
        ROAD_STYLE = new OpenLayers.Style({
            pointRadius: 12,
            fillColor: '#369',
            pathLabel: '${label}',
            label: '',
            fontColor: '#faf',
            labelSelect: true,
            pathLabelYOffset: '${getOffset}',
            pathLabelCurve: '${getSmooth}',
            pathLabelReadable: '${getReadable}',
            labelAlign: '${getAlign}',
            labelOutlineWidth: 3,
            labelOutlineColor: '#000',
            strokeWidth: 3,
            stroke: true,
            strokeColor: '#f0f',
            strokeOpacity: 0.4,
            fontWeight: 'bold',
            fontSize: 11
        }, {
            context: {
                getOffset() { return -(W.map.getZoom() + 5); },
                getSmooth() { return ''; },
                getReadable() { return '1'; },
                getAlign() { return 'cb'; }
            }
        });
    }

    // eslint-disable-next-line no-unused-vars
    const _regexReplace = {
        // Strip leading zeros or blank full label for any label starting with a non-digit or
        // is a Zero Address, use with '' as replace.
        r0: /^(0+(\s.*)?|\D.*)/,
        // Strip Everything After Street Type to end of the string by use $1 and $2 capture
        // groups, use with replace '$1$2'
        // eslint-disable-next-line max-len
        r1: /^(.* )(Ave(nue)?|Dr(ive)?|St(reet)?|C(our)?t|Cir(cle)?|Blvd|Boulevard|Pl(ace)?|Ln|Lane|Fwy|Freeway|R(oa)?d|Ter(r|race)?|Tr(ai)?l|Way|Rte \d+|Route \d+)\b.*/gi,
        // Strip SPACE 5 Digits from end of string, use with replace ''
        r2: /\s\d{5}$/,
        // Strip Everything after a "~", ",", ";" to the end of the string, use with replace ''
        r3: /(~|,|;|\s?\r\n).*$/,
        // Move the digits after the last space to before the rest of the string using, use with
        // replace '$2 $1'
        r4: /^(.*)\s(\d+).*/,
        // Insert newline between digits (including "-") and everything after the digits,
        // except(and before) a ",", use with replace '$1\n$2'
        r5: /^([-\d]+)\s+([^,]+).*/,
        // Insert newline between digits and everything after the digits, use with
        // replace '$1\n$2'
        r6: /^(\d+)\s+(.*)/
    };

    let _gisLayers = [];

    // const _layerRefinements = [
    //     {
    //         id: 'us-post-offices',
    //         labelHeaderFields: ['LOCALE_NAME']
    //     }
    // ];

    const STATES = {
        _states: [
            ['PRY (Pais)', 'PRY', -1], ['Asuncion (Capital)', 'ASU', 0], ['Concepcion', 'CON', 1],
            ['San Pedro', 'SAN', 2], ['Cordillera', 'COR', 3], ['Guaira', 'GUA', 4],
            ['Caaguazu', 'CAG', 5], ['Caazapa', 'CAZ', 6], ['Itapua', 'ITA', 7],
            ['Misiones', 'MIS', 8], ['Paraguari', 'PAR', 9], ['Alto Parana', 'ANA', 10],
            ['Central', 'CEN', 11], ['Neembucu', 'NEE', 12], ['Amambay', 'AMA', 13], ['Canindeyu', 'CAN', 14],
            ['Presidente Hayes', 'PHA', 15], ['Boqueron', 'BOQ', 16], ['Alto Paraguay', 'AAY', 17],
        ],
        toAbbr(fullName) { return this._states.find(a => a[0] === fullName)[1]; },
        toFullName(abbr) { return this._states.find(a => a[1] === abbr)[0]; },
        toFullNameArray() { return this._states.map(a => a[0]); },
        toAbbrArray() { return this._states.map(a => a[1]); },
        fromId(id) { return this._states.find(a => a[2] === id); }
    };
    const DEFAULT_VISIBLE_AT_ZOOM = 6;
    const SETTINGS_STORE_NAME = 'wme_gis_layers';
    const COUNTIES_URL = 'https://analisis.stp.gov.py:443/user/ine/api/v2/';
    // const COUNTIES_URL2 = 'https://services2.arcgis.com/tnyi76ruua1nbtl3/ArcGIS/rest/services/Paraguay_Interactive/FeatureServer/0';
    const COUNTIES_URL2 = 'https://services2.arcgis.com/Xim64FzemN4fqY1y/ArcGIS/rest/services/PY_Departamentos_y_Municipios/FeatureServer/0';
    const ALERT_UPDATE = false;
    const SCRIPT_NAME = GM_info.script.name;
    const SCRIPT_VERSION = GM_info.script.version;
    const DOWNLOAD_URL = 'https://greatest.deepsurf.us/scripts/388277-wme-paraguay-gis-layers/code/WME%20Paraguay%20GIS%20Layers.user.js';
    const SCRIPT_VERSION_CHANGES = [];
    let _mapLayer = null;
    let _roadLayer = null;
    let _settings = {};
    let _ignoreFetch = false;
    let _lastToken = {};

    const DEBUG = true;
    //function log(message) { console.log('PY GIS Layers:', message); }
    function logError(message) { console.error(`${SCRIPT_NAME}:`, message); }
    function logDebug(message) { if (DEBUG) console.debug(`${SCRIPT_NAME}:`, message); }
    // function logWarning(message) { console.warn('PY GIS Layers:', message); }

    let _layerSettingsDialog;

    class LayerSettingsDialog {
        constructor() {
            this._$titleText = $('<span>');
            this._$closeButton = $('<span>', {
                style: 'cursor:pointer;padding-left:4px;font-size:17px;color:#d6e6f3;float:right;',
                class: 'fa fa-window-close'
            }).click(() => this._onCloseButtonClick());
            this._$shiftUpButton = LayerSettingsDialog._createShiftButton('fa-angle-up').click(() => this._onShiftButtonClick(0, 1));
            this._$shiftLeftButton = LayerSettingsDialog._createShiftButton('fa-angle-left').click(() => this._onShiftButtonClick(-1, 0));
            this._$shiftRightButton = LayerSettingsDialog._createShiftButton('fa-angle-right').click(() => this._onShiftButtonClick(1, 0));
            this._$shiftDownButton = LayerSettingsDialog._createShiftButton('fa-angle-down').click(() => this._onShiftButtonClick(0, -1));
            this._$resetButton = $('<button>', {
                class: 'form-control',
                style: 'height: 24px; width: auto; padding: 2px 6px 0px 6px; display: inline-block; float: right;'
            }).text('Reset').click(() => this._onResetButtonClick());

            this._dialogDiv = $('<div>', {
                style: 'position: fixed; top: 15%; left: 400px; width: 200px; z-index: 100; background-color: #73a9bd; border-width: 1px; border-style: solid;'
                    + 'border-radius: 10px; box-shadow: 5px 5px 10px rgba(0, 0, 0, 0.7); border-color: #50667b; padding: 4px;'
            }).append($('<div>').append( // The extra div is needed here. When the header text wraps, the main dialog div won't expand properly without it.
                // HEADER
                $('<div>', { style: 'border-radius:5px 5px 0px 0px; padding: 4px; color: #fff; font-weight: bold; text-align:left; cursor: default;' }).append(
                    this._$closeButton,
                    this._$titleText
                ),
                // BODY
                $('<div>', { style: 'border-radius: 5px; width: 100%; padding: 4px; background-color:#d6e6f3; display:inline-block; margin-right:5px;' }).append(
                    this._$resetButton,
                    $('<input>', {
                        type: 'radio', id: 'gisLayerShiftAmt1', name: 'gisLayerShiftAmt', value: '1', checked: 'checked'
                    }),
                    $('<label>', { for: 'gisLayerShiftAmt1' }).text('1m'),
                    $('<input>', {
                        type: 'radio', id: 'gisLayerShiftAmt10', name: 'gisLayerShiftAmt', value: '10', style: 'margin-left: 6px'
                    }),
                    $('<label>', { for: 'gisLayerShiftAmt10' }).text('10m'),
                    $('<div>', { style: 'padding: 4px' }).append(
                        $('<table>', { style: 'table-layout:fixed; width:60px; height:84px; margin-left:auto;margin-right:auto;' }).append(
                            $('<tr>', { style: 'width: 20px; height: 28px;' }).append(
                                $('<td>', { align: 'center' }),
                                $('<td>', { align: 'center' }).append(this._$shiftUpButton),
                                $('<td>', { align: 'center' })
                            ),
                            $('<tr>', { style: 'width: 20px; height: 28px;' }).append(
                                $('<td>', { align: 'center' }).append(this._$shiftLeftButton),
                                $('<td>', { align: 'center' }),
                                $('<td>', { align: 'center' }).append(this._$shiftRightButton)
                            ),
                            $('<tr>', { style: 'width: 20px; height: 28px;' }).append(
                                $('<td>', { align: 'center' }),
                                $('<td>', { align: 'center' }).append(this._$shiftDownButton),
                                $('<td>', { align: 'center' })
                            )
                        )
                    )
                )
            ));

            this.hide();
            this._dialogDiv.appendTo('body');

            if (typeof jQuery.ui !== 'undefined') {
                const that = this;
                this._dialogDiv.draggable({
                    // Gotta nuke the height setting the dragging inserts otherwise the panel cannot dynamically resize
                    stop() { that._dialogDiv.css('height', ''); }
                });
            }
        }

        get gisLayer() {
            return this._gisLayer;
        }

        set gisLayer(value) {
            if (value !== this._gisLayer) {
                this._gisLayer = value;
                this.title = value.name;
            }
        }

        get title() {
            return this._$titleText.text();
        }

        set title(value) {
            this._$titleText.text(value);
        }

        // eslint-disable-next-line class-methods-use-this
        getShiftAmount() {
            return $('input[name=gisLayerShiftAmt]:checked').val();
        }

        show() {
            this._dialogDiv.show();
        }

        hide() {
            this._dialogDiv.hide();
        }

        _onCloseButtonClick() {
            this.hide();
        }

        _onShiftButtonClick(x, y) {
            const shiftAmount = this.getShiftAmount();
            x *= shiftAmount;
            y *= shiftAmount;
            this._shiftLayerFeatures(x, y);
            const { id } = this._gisLayer;
            let offset = _settings.getLayerSetting(id, 'offset');
            if (!offset) {
                offset = { x: 0, y: 0 };
                _settings.setLayerSetting(id, 'offset', offset);
            }
            offset.x += x;
            offset.y += y;
            saveSettingsToStorage();
        }

        _onResetButtonClick() {
            const offset = _settings.getLayerSetting(this._gisLayer.id, 'offset');
            if (offset) {
                this._shiftLayerFeatures(offset.x * -1, offset.y * -1);
                delete _settings.layers[this._gisLayer.id].offset;
                saveSettingsToStorage();
            }
        }

        _shiftLayerFeatures(x, y) {
            const layer = this.gisLayer.isRoadLayer ? _roadLayer : _mapLayer;
            layer.getFeaturesByAttribute('layerID', this.gisLayer.id).forEach(f => f.geometry.move(x, y));
            layer.redraw();
        }

        static _createShiftButton(fontAwesomeClass) {
            return $('<button>', {
                class: 'form-control',
                style: 'cursor:pointer;font-size:14px;padding: 3px;border-radius: 5px;width: 21px;height: 21px;'
            }).append(
                $('<i>', { class: 'fa', style: 'vertical-align: super' }).addClass(fontAwesomeClass)
            );
        }
    }

    function loadSettingsFromStorage() {
        const loadedSettings = $.parseJSON(localStorage.getItem(SETTINGS_STORE_NAME));
        const defaultSettings = {
            lastVersion: null,
            visibleLayers: [],
            onlyShowApplicableLayers: false,
            selectedStates: [],
            enabled: true,
            fillParcels: false,
            toggleHnsOnlyShortcut: '',
            oneTimeAlerts: {},
            layers: {}
        };
        _settings = loadedSettings || defaultSettings;
        Object.keys(defaultSettings).forEach(prop => {
            if (!_settings.hasOwnProperty(prop)) {
                _settings[prop] = defaultSettings[prop];
            }
        });

        _settings.getLayerSetting = function getLayerSetting(layerID, settingName) {
            const layerSettings = this.layers[layerID];
            if (!layerSettings) {
                return undefined;
            }
            return layerSettings[settingName];
        };
        _settings.setLayerSetting = function setLayerSetting(layerID, settingName, value) {
            let layerSettings = this.layers[layerID];
            if (!layerSettings) {
                layerSettings = {};
                this.layers[layerID] = layerSettings;
            }
            layerSettings[settingName] = value;
        };
    }

    function saveSettingsToStorage() {
        // Check for existance of action first, due to WME beta issue.
        if (W.accelerators.Actions.GisLayersAddrDisplay) {
            let keys = '';
            const { shortcut } = W.accelerators.Actions.GisLayersAddrDisplay;
            if (shortcut) {
                if (shortcut.altKey) keys += 'A';
                if (shortcut.shiftKey) keys += 'S';
                if (shortcut.ctrlKey) keys += 'C';
                if (keys.length) keys += '+';
                if (shortcut.keyCode) keys += shortcut.keyCode;
            }
            _settings.toggleHnsOnlyShortcut = keys;
        }
        _settings.lastVersion = SCRIPT_VERSION;
        localStorage.setItem(SETTINGS_STORE_NAME, JSON.stringify(_settings));
        logDebug('Configuracion guardada');
    }

    function getUrl(extent, gisLayer) {
        if (gisLayer.spatialReference) {
            const proj = new OpenLayers.Projection(`EPSG:${gisLayer.spatialReference}`);
            let new_extent = extent.clone();
            new_extent.transform(W.map.getProjectionObject(), proj); // do not transform original extent
            extent = new_extent;
        }
        let layerOffset = _settings.getLayerSetting(gisLayer.id, 'offset');
        if (!layerOffset) {
            layerOffset = { x: 0, y: 0 };
        }
        const geometry = {
            xmin: extent.left - layerOffset.x,
            ymin: extent.bottom - layerOffset.y,
            xmax: extent.right - layerOffset.x,
            ymax: extent.top - layerOffset.y,
            spatialReference: {
                wkid: gisLayer.spatialReference ? gisLayer.spatialReference : 102100,
                latestWkid: gisLayer.spatialReference ? gisLayer.spatialReference : 3857
            }
        };
        const geometryStr = JSON.stringify(geometry);
        let fields = gisLayer.labelFields.filter(function (e) { return e != ""});
        if (gisLayer.labelHeaderFields) {
            fields = fields.concat(gisLayer.labelHeaderFields);
        }
        if (gisLayer.distinctFields) {
            fields = fields.concat(gisLayer.distinctFields);
        }
        let url = ""
        if (gisLayer.isFeatureSet) {
            url = gisLayer.url; // no extra filters for this resource (caching)
        } else if (gisLayer.serverType == "GeoNode"){
            url = gisLayer.url;
            url += `&CRS=EPSG:${geometry.spatialReference.latestWkid}`;
            if (gisLayer.where){
                var geom_field = gisLayer.cql_the_geom ? gisLayer.cql_the_geom : "the_geom"; //some geom fields are called simply 'geom'
                var where = `(bbox(${geom_field},${geometry.xmin},${geometry.ymin},${geometry.xmax},${geometry.ymax},'EPSG:${geometry.spatialReference.latestWkid}') and ${gisLayer.where})`;
                url += `&cql_filter=${encodeURIComponent(where)}`;
            } else {
                url += `&bbox=${geometry.xmin},${geometry.ymin},${geometry.xmax},${geometry.ymax},EPSG:${geometry.spatialReference.latestWkid}`;
            }
            url += `&srsName=EPSG:${geometry.spatialReference.latestWkid}&outputFormat=${gisLayer.output? gisLayer.output : "application/json"}`;
        } else if (gisLayer.serverType == "CartoDB"){
             // url with query format 'SELECT the_geom_webmercator AS the_geom FROM user.table_name'
            url =`${gisLayer.url} WHERE ST_Intersects(ST_SetSRID(ST_MakeBox2D(ST_Point(${extent.left},${extent.top}),ST_Point(${extent.right},${extent.bottom})),3857),the_geom_webmercator)`;
            if (fields.length){
                url = url.replace("the_geom_webmercator AS the_geom", `the_geom_webmercator AS the_geom%2C${encodeURIComponent(fields.join(','))}`)
            }
            if (gisLayer.where){
                url += `AND ${gisLayer.where}`;
            }
            url += '&format=GeoJSON'
        } else { //default ArcGIS server
            url = `${gisLayer.url}/query?geometry=${encodeURIComponent(geometryStr)}`;
            url += gisLayer.token ? `&token=${gisLayer.token}` : '';
            url += `&outFields=${encodeURIComponent(fields.join(','))}`;
            url += '&returnGeometry=true&spatialRel=esriSpatialRelIntersects&geometryType=esriGeometryEnvelope';
            url += `&inSR=${gisLayer.spatialReference ? gisLayer.spatialReference : '102100'}`;
            url += '&outSR=3857&f=json';
            url += gisLayer.where ? `&where=${encodeURIComponent(gisLayer.where)}` : '';
        }

        logDebug(`Request URL: ${url}`);
        return url;
    }

    function hashString(value) {
        let hash = 0;
        if (value.length === 0) return hash;
        for (let i = 0; i < value.length; i++) {
            const chr = value.charCodeAt(i);
            // eslint-disable-next-line no-bitwise
            hash = ((hash << 5) - hash) + chr;
            // eslint-disable-next-line no-bitwise
            hash |= 0; // Convert to 32bit integer
        }
        return hash;
    }

    function getCountiesUrl(extent) {
         const geometry = {
             xmin: extent.left,
             ymin: extent.bottom,
             xmax: extent.right,
             ymax: extent.top,
             spatialReference: { wkid: 102100, latestWkid: 3857 }
         };
        const url = `${COUNTIES_URL2}/query?geometry=${encodeURIComponent(JSON.stringify(geometry))}`;
        return `${url}&outFields=NAME as BASENAME%2CCODE as STATE&returnGeometry=false&spatialRel=esriSpatialRelIntersects`
             + '&geometryType=esriGeometryEnvelope&inSR=102100&outSR=3857&f=json';

        /*const url = `${COUNTIES_URL}sql?q=SELECT dist_desc_ AS BASENAME, dpto AS STATE FROM ine.paraguay_2019_distritos `;
        var gps1 = WazeWrap.Geometry.ConvertTo4326(extent.left, extent.top);
        var gps2 = WazeWrap.Geometry.ConvertTo4326(extent.right, extent.bottom);
        return `${url} WHERE ST_Intersects(
           ST_SetSRID(
               ST_MakeBox2D(
                   ST_Point(${gps1.lon},${gps1.lat}),
                   ST_Point(${gps2.lon},${gps2.lat})
               ),
               4326
           ),
           the_geom)`;*/
    }

    let _countiesInExtent = [];
    let _statesInExtent = [];

    function getFetchableLayers(getInvisible) {
        if (W.map.getZoom() < 12 - 12) return []; //TODO: CHECK THIS LINE
        return _gisLayers.filter(gisLayer => {
            const isValidUrl = gisLayer.url && gisLayer.url.trim().length > 0;
            const isVisible = (getInvisible || _settings.visibleLayers.includes(gisLayer.id))
                && _settings.selectedStates.includes(gisLayer.state);
            const isInState = gisLayer.state === 'PRY' || _countiesInExtent.some(county => county.stateInfo[1] === gisLayer.state);
            // Be sure to use hasOwnProperty when checking this, since 0 is a valid value.
            const isValidZoom = getInvisible || W.map.getZoom() - 12 >= (gisLayer.hasOwnProperty('visibleAtZoom')
                ? gisLayer.visibleAtZoom : DEFAULT_VISIBLE_AT_ZOOM);
            return isValidUrl && isInState && isVisible && isValidZoom;
        });
    }

    function filterLayerCheckboxes() {
        const applicableLayers = getFetchableLayers(true).filter(layer => {
            const hasCounties = layer.hasOwnProperty('counties');
            return (hasCounties && layer.counties.some(countyName => _countiesInExtent.some(county => county.name === countyName.toLowerCase()
                && layer.state === county.stateInfo[1]))) || !hasCounties;
        });
        const statesToHide = STATES.toAbbrArray();

        _gisLayers.forEach(gisLayer => {
            const id = `#gis-layer-${gisLayer.id}-container`;
            if (!_settings.onlyShowApplicableLayers || applicableLayers.includes(gisLayer)) {
                $(id).show();
                $(`#gis-layers-for-${gisLayer.state}`).show();
                const idx = statesToHide.indexOf(gisLayer.state);
                if (idx > -1) statesToHide.splice(idx, 1);
            } else {
                $(id).hide();
            }
        });
        if (_settings.onlyShowApplicableLayers) {
            statesToHide.forEach(st => $(`#gis-layers-for-${st}`).hide());
        }
    }

    function convertFeatureGeometry(gisLayer, featureGeometry) {
        if (gisLayer.spatialReference) {
            const proj = new OpenLayers.Projection(`EPSG:${gisLayer.spatialReference}`);
            featureGeometry.transform(proj, W.map.getProjectionObject());
        }
        return featureGeometry;
    }

    function setStateFullAddress() {
            if (document.getElementsByClassName("location-info")){
                var full = document.getElementsByClassName("location-info")[0];
                if (full != undefined){
                    var yy = full.innerText;
                    if (yy.includes("Paraguay")){
                        var deptos = _statesInExtent.join(', ');
                        yy = yy.replace(/\[.*\]/g, '');
                        yy += " [" + deptos + "]";
                        document.getElementsByClassName("location-info")[0].innerText = yy;
                    }
                }
            }
    }

    const ROAD_ABBR = [
        [/\bAVDA./gi, 'Av.'], [/\bAVENIDA/gi, 'Av.'], [/\bCOURT$/, 'CT'], [/\bDRIVE$/, 'DR'],
        [/\bLANE$/, 'LN'], [/\bPARK$/, 'PK'], [/\bPLACE$/, 'PL'], [/\bROAD$/, 'RD'], [/\bSTREET$/, 'ST'],
        [/\bTERRACE$/, 'TER']
    ];
    function processFeatures(data, token, gisLayer) {
        const features = [];
        if (data.skipIt) {
            // do nothing
        } else if (data.error) {
            logError(`Error in layer "${gisLayer.name}": ${data.error.message}`);
        } else {
            let items = {}
            if (gisLayer.isFeatureSet){
                // storing result as cache if not already there
                if (!sessionStorage.getItem(gisLayer.id)){
                    sessionStorage.setItem(gisLayer.id, JSON.stringify(data));
                }
                if (gisLayer.isFeatureSet == 1) {
                    items = data.layers[0].featureSet.features;
                } else if (gisLayer.isFeatureSet == 2){ // 2 is for GeoNode
                    items = data.features;
                } else if (gisLayer.isFeatureSet == 3){ // RawData
                    items = data;
                }
            } else {
                items = data.features || [];
            }
            if (!token.cancel) {
                let error = false;
                const distinctValues = [];
                items.forEach(item => {
                    let skipIt = false;
                    if (!token.cancel && !error) {
                        let feature;
                        let featureGeometry;
                        let area;
                        if (gisLayer.distinctFields) {
                            if (distinctValues.some(v => gisLayer.distinctFields.every(
                                fld => v[fld] === item.attributes[fld]
                            ))) {
                                skipIt = true;
                            } else {
                                const dist = {};
                                gisLayer.distinctFields.forEach(fld => (dist[fld] = item.attributes[fld]));
                                distinctValues.push(dist);
                            }
                        }
                        if (!skipIt) {
                            let isPolyLine = false;
                            let layerOffset = _settings.getLayerSetting(gisLayer.id, 'offset');
                            if (!layerOffset) {
                                layerOffset = { x: 0, y: 0 };
                            }
                            // Special handling for this layer, because it doesn't have a geometry property.
                            // Coordinates are stored in the attributes.
                            // if (gisLayer.id === 'nc-richmond-co-pts') {
                            //     const pt = new OpenLayers.Geometry.Point(item.attributes.XCOOR, item.attributes.YCOOR);
                            //     pt.transform(W.map.getOLMap().displayProjection, W.map.getProjectionObject());
                            //     item.geometry = pt;
                            // }
                            if (!item.geometry && ["RawPointData",].indexOf(gisLayer.serverType) >= 0){
                                item.geometry = "RawPointData"
                            }
                            if (item.geometry) {
                                if (item.geometry.x) {
                                    featureGeometry = new OpenLayers.Geometry.Point(
                                        item.geometry.x + layerOffset.x,
                                        item.geometry.y + layerOffset.y
                                    );
                                } else if (item.geometry.points) {
                                    // @TODO Fix for multiple points instead of just grabbing first.
                                    featureGeometry = new OpenLayers.Geometry.Point(
                                        item.geometry.points[0][0] + layerOffset.x,
                                        item.geometry.points[0][1] + layerOffset.y
                                    );
                                } else if (item.geometry.rings) {
                                    const rings = [];
                                    item.geometry.rings.forEach(ringIn => {
                                        const pnts = [];
                                        for (let i = 0; i < ringIn.length; i++) {
                                            pnts.push(new OpenLayers.Geometry.Point(
                                                ringIn[i][0] + layerOffset.x,
                                                ringIn[i][1] + layerOffset.y
                                            ));
                                        }
                                        rings.push(new OpenLayers.Geometry.LinearRing(pnts));
                                    });
                                    featureGeometry = new OpenLayers.Geometry.Polygon(rings);
                                    if (gisLayer.areaToPoint) {
                                        featureGeometry = featureGeometry.getCentroid();
                                    } else {
                                        area = featureGeometry.getArea();
                                    }
                                } else if (data.geometryType === 'esriGeometryPolyline') {
                                    // We have to handle polylines differently since each item can have multiple features.
                                    // In terms of ArcGIS, each feature's geometry can have multiple paths.  For instance
                                    // a single road can be broken into parts that are physically not connected to each other.
                                    let label = '';
                                    const hasVisibleAtZoom = gisLayer.hasOwnProperty('visibleAtZoom');
                                    const hasLabelsVisibleAtZoom = gisLayer.hasOwnProperty('labelsVisibleAtZoom');
                                    const displayLabelsAtZoom = hasLabelsVisibleAtZoom ? gisLayer.labelsVisibleAtZoom
                                        : (hasVisibleAtZoom ? gisLayer.visibleAtZoom : DEFAULT_VISIBLE_AT_ZOOM) + 1;
                                    if (gisLayer.labelHeaderFields) {
                                        label = `${gisLayer.labelHeaderFields.map(
                                            fieldName => item.attributes[fieldName]
                                        ).join(' ').trim()}\n`;
                                    }
                                    if (W.map.getZoom() - 12 >= displayLabelsAtZoom || area >= 5000) {  //TODO: CHECK THIS LINE
                                        label += gisLayer.labelFields.map(
                                            fieldName => item.attributes[fieldName]
                                        ).join(' ').trim();
                                        if (gisLayer.processLabel) {
                                            label = gisLayer.processLabel(label, item.attributes);
                                            label = label ? label.trim() : '';
                                        }
                                    }

                                    // Use Turf library to clip the geometry to the screen bounds.
                                    // This allows labels to stay in view on very long roads.
                                    const mls = turf.multiLineString(item.geometry.paths);
                                    const e = W.map.getExtent();
                                    const bbox = [e.left, e.bottom, e.right, e.top];
                                    const clipped = turf.bboxClip(mls, bbox);
                                    if (clipped.geometry.type === 'LineString') {
                                        item.geometry.paths = [clipped.geometry.coordinates];
                                    } else if (clipped.geometry.type === 'MultiLineString') {
                                        item.geometry.paths = clipped.geometry.coordinates;
                                    }

                                    item.geometry.paths.forEach(path => {
                                        const pointList = [];
                                        path.forEach(point => pointList.push(new OpenLayers.Geometry.Point(
                                            point[0] + layerOffset.x,
                                            point[1] + layerOffset.y
                                        )));
                                        featureGeometry = new OpenLayers.Geometry.LineString(pointList);
                                        featureGeometry.skipDupeCheck = true;

                                        const attributes = {
                                            layerID: gisLayer.id,
                                            label
                                        };

                                        const lineFeature = new OpenLayers.Feature.Vector(featureGeometry, attributes);
                                        features.push(lineFeature);
                                    });
                                    isPolyLine = true;
                                } else if (["GeoNode", "CartoDB"].indexOf(gisLayer.serverType) >= 0){
                                    if (item.geometry.type == "GeometryCollection") {
                                        let props = item.properties;
                                        item = item.geometry.geometries[0];
                                        item.geometry = item;
                                        item.properties = props;
                                    }
                                    if (item.geometry.type == "Point") {
                                        featureGeometry = new OpenLayers.Geometry.Point(
                                            item.geometry.coordinates[0] + layerOffset.x,
                                            item.geometry.coordinates[1] + layerOffset.y
                                        );
                                    } else if (item.geometry.type == "MultiPoint") {
                                        const rings = [];
                                        const pnts = [];
                                        item.geometry.coordinates.forEach(ringIn => {
                                            pnts.push(new OpenLayers.Geometry.Point(
                                                ringIn[0] + layerOffset.x,
                                                ringIn[1] + layerOffset.y
                                            ));
                                        });
                                        rings.push(new OpenLayers.Geometry.LinearRing(pnts));
                                        featureGeometry = new OpenLayers.Geometry.Polygon(rings);
                                    } else if (item.geometry.type == "Polygon") {
                                        const rings = [];
                                        item.geometry.coordinates.forEach(ringIn => {
                                            const pnts = [];
                                            for (let i = 0; i < ringIn.length; i++) {
                                                pnts.push(new OpenLayers.Geometry.Point(
                                                    ringIn[i][0] + layerOffset.x,
                                                    ringIn[i][1] + layerOffset.y
                                                ));
                                            }
                                            rings.push(new OpenLayers.Geometry.LinearRing(pnts));
                                        });
                                        featureGeometry = new OpenLayers.Geometry.Polygon(rings);
                                        if (gisLayer.areaToPoint) {
                                            featureGeometry = featureGeometry.getCentroid();
                                        } else {
                                            area = featureGeometry.getArea();
                                        }
                                    } else if (item.geometry.type == "MultiPolygon") {
                                        const source = item.geometry.coordinates[0];
                                        const polygonList = [];
                                        for (var i = 0; i < source.length; i += 1) {
                                            const pointList = [];
                                            for (var j = 0; j < source[i].length; j += 1) {
                                                var point = new OpenLayers.Geometry.Point(
                                                    source[i][j][0],
                                                    source[i][j][1]
                                                );
                                                pointList.push(point);
                                            }
                                            var linearRing = new OpenLayers.Geometry.LinearRing(pointList);
                                            var polygon = new OpenLayers.Geometry.Polygon([linearRing]);
                                            polygonList.push(polygon);
                                        }
                                        featureGeometry = new OpenLayers.Geometry.MultiPolygon(polygonList);
                                    } else if (item.geometry.type == "MultiLineString") {
                                        const pointList = [];
                                        item.geometry.coordinates.forEach(path => {
                                            path.forEach(point => pointList.push(new OpenLayers.Geometry.Point(
                                                point[0] + layerOffset.x,
                                                point[1] + layerOffset.y
                                            )));
                                        });
                                        featureGeometry = new OpenLayers.Geometry.LineString(pointList);
                                        featureGeometry.skipDupeCheck = true;
                                    } else if (item.geometry.type == "LineString") {
                                        const pointList = [];
                                        item.geometry.coordinates.forEach(point => {
                                            pointList.push(new OpenLayers.Geometry.Point(
                                                point[0] + layerOffset.x,
                                                point[1] + layerOffset.y
                                            ));
                                        });
                                        featureGeometry = new OpenLayers.Geometry.LineString(pointList);
                                    }
                                    featureGeometry = convertFeatureGeometry(gisLayer, featureGeometry);
                                } else if (["RawPointData",].indexOf(gisLayer.serverType) >= 0){
                                    featureGeometry = new OpenLayers.Geometry.Point(item[`${gisLayer.processLon}`] + layerOffset.x, item[`${gisLayer.processLat}`] + layerOffset.y);
                                    featureGeometry = convertFeatureGeometry(gisLayer, featureGeometry);
                                } else {
                                    logDebug(`Unexpected feature type in layer: ${JSON.stringify(item)}`);
                                    logError(`Error: Unexpected feature type in layer "${gisLayer.name}"`);
                                    error = true;
                                }
                                if (!error && !isPolyLine) {
                                    const hasVisibleAtZoom = gisLayer.hasOwnProperty('visibleAtZoom');
                                    const hasLabelsVisibleAtZoom = gisLayer.hasOwnProperty('labelsVisibleAtZoom');
                                    const displayLabelsAtZoom = hasLabelsVisibleAtZoom ? gisLayer.labelsVisibleAtZoom
                                        : (hasVisibleAtZoom ? gisLayer.visibleAtZoom : DEFAULT_VISIBLE_AT_ZOOM) + 1;
                                    let label = '';
                                    let attrs = [];
                                    if (["GeoNode", "CartoDB"].indexOf(gisLayer.serverType) >= 0){
                                        attrs = item.properties;
                                    } else if (["RawPointData"].indexOf(gisLayer.serverType) >= 0) {
                                        attrs = item;
                                    } else {
                                        attrs = item.attributes;
                                    }
                                    if (gisLayer.labelHeaderFields) {
                                        label = `${gisLayer.labelHeaderFields.map(
                                            fieldName => attrs[fieldName]
                                        ).join(' ').trim()}\n`;
                                    }
                                    if (W.map.getZoom() - 12 >= displayLabelsAtZoom || area >= 5000) {
                                        label += gisLayer.labelFields.map(
                                            fieldName => attrs[fieldName]
                                        ).join(' ').trim();
                                        if (gisLayer.processLabel) {

                                            label = gisLayer.processLabel(label, attrs);
                                            label = label ? label.trim() : '';
                                        }
                                    }
                                    if (label && [
                                        LAYER_STYLES.points, LAYER_STYLES.parcels, LAYER_STYLES.state_points,
                                        LAYER_STYLES.state_parcels
                                    ].includes(gisLayer.style)) {
                                        if (_settings.addrLabelDisplay === 'hn') {
                                            const m = label.match(/^\d+/);
                                            label = m ? m[0] : '';
                                        } else if (_settings.addrLabelDisplay === 'street') {
                                            const m = label.match(/^(?:\d+\s)?(.*)/);
                                            label = m ? m[1].trim() : '';
                                        }
                                        else if (_settings.addrLabelDisplay === 'none') {
                                            label = '';
                                        }
                                    }
                                    const attributes = {
                                        layerID: gisLayer.id,
                                        label
                                    };
                                    if (gisLayer.isFeatureSet){
                                        // avoid drawing features that are not in extent
                                        const isFeatureInExtent = W.map.getExtent().intersectsBounds(featureGeometry.getBounds());
                                        if (!isFeatureInExtent) return;
                                    }
                                    feature = new OpenLayers.Feature.Vector(featureGeometry, attributes);
                                    features.push(feature);
                                }
                            }
                        }
                    }
                });
            }
        }
        if (!token.cancel) {
            // Check for duplicate geometries.
            for (let i = 0; i < features.length; i++) {
                const f1 = features[i];
                let labels = [f1.attributes.label];
                if (!f1.geometry.skipDupeCheck) {
                    const c1 = f1.geometry.getCentroid();

                    for (let j = i + 1; j < features.length; j++) {
                        const f2 = features[j];
                        if (!f2.geometry.skipDupeCheck && f2.geometry.getCentroid().distanceTo(c1) < 1) {
                            features.splice(j, 1);
                            labels.push(f2.attributes.label);
                            j--;
                        }
                    }
                }
                    labels = _.uniq(labels);
                    if (labels.length > 1) {
                        labels.forEach((label, idx) => {
                            label = label.replace(/\n/g, ' ').replace(/\s{2,}/, ' ').replace(/\bUNIT\s.{1,5}$/i, '').trim();
                            ROAD_ABBR.forEach(abbr => (label = label.replace(abbr[0], abbr[1])));
                            labels[idx] = label;
                        });
                        labels = _.uniq(labels);
                        labels.sort();
                        if (labels.length > 12) {
                            const len = labels.length;
                            labels = labels.slice(0, 10);
                            labels.push(`(${len - 10} more...)`);
                        }
                        f1.attributes.label = _.uniq(labels).join('\n');
                    } else {
                        let { label } = f1.attributes;
                        ROAD_ABBR.forEach(abbr => (label = label.replace(abbr[0], abbr[1])));
                        f1.attributes.label = label;
                }
            }

            const layer = gisLayer.isRoadLayer ? _roadLayer : _mapLayer;
            layer.removeFeatures(layer.getFeaturesByAttribute('layerID', gisLayer.id));
            layer.addFeatures(features);

            if (features.length) {
                $(`label[for="gis-layer-${gisLayer.id}"]`).css({ color: '#00a009' });
            }
        }
    } // END processFeatures()

    function fetchFeatures() {
        if (!_settings.enabled) return;
        if (_ignoreFetch) return;
        if (W.map.getZoom() < 12 - 12) {// TODO: CHECK THIS LINE
            filterLayerCheckboxes();
            return;
        }
        _lastToken.cancel = true;
        _lastToken = { cancel: false, features: [], layersProcessed: 0 };
        $('.gis-state-layer-label').css({ color: '#777' });

        let _layersCleared = false;

        // if (layersToFetch.length) {
        const extent = W.map.getExtent();
        GM_xmlhttpRequest({
            url: getCountiesUrl(extent),
            method: 'GET',
            onload(res) {
                if (res.status < 400) {
                    const data = $.parseJSON(res.responseText);
                    if (data.error) {
                        logError(`Error in PY Census counties data: ${data.error.message}`);
                    } else {
                        _countiesInExtent = data.features.map(feature => {
                            const name = feature.attributes.BASENAME.toLowerCase();
                            const stateInfo = STATES.fromId(parseInt(feature.attributes.STATE, 10));
                            return { name, stateInfo };
                        });
                        logDebug(`PY Census counties: ${_countiesInExtent.map(c => `${c.name} ${c.stateInfo[1]}`).join(', ')}`);
                        _statesInExtent = _.uniq(data.features.map(
                            // eslint-disable-next-line radix
                            feature => STATES.fromId(parseInt(feature.attributes.STATE, 10))[0]
                        ));
                        setStateFullAddress();
                        let layersToFetch;
                        if (!_layersCleared) {
                            _layersCleared = true;
                            layersToFetch = getFetchableLayers();

                            // Remove features of any layers that won't be mapped.
                            _gisLayers.forEach(gisLayer => {
                                if (!layersToFetch.includes(gisLayer)) {
                                    _mapLayer.removeFeatures(_mapLayer.getFeaturesByAttribute('layerID', gisLayer.id));
                                    _roadLayer.removeFeatures(_roadLayer.getFeaturesByAttribute('layerID', gisLayer.id));
                                }
                            });
                        }

                        layersToFetch = layersToFetch.filter(layer => !layer.hasOwnProperty('counties')
                            || layer.counties.some(countyName => _countiesInExtent.some(county => county.name === countyName.toLowerCase()
                                && layer.state === county.stateInfo[1])));
                        filterLayerCheckboxes();
                        logDebug(`Fetching ${layersToFetch.length} layers...`);
                        logDebug(layersToFetch);
                        layersToFetch.forEach(gisLayer => {
                            const url = getUrl(extent, gisLayer);
                            if (gisLayer.isFeatureSet){ // trying to retrieve cached data from sessionStorage
                                let sessionValue = sessionStorage.getItem(gisLayer.id);
                                if (sessionValue){
                                    logDebug(`Processing features of ${gisLayer.id} from storage (RawData)...`);
                                    processFeatures($.parseJSON(sessionValue), {}, gisLayer);
                                    return;
                                }
                            }
                            GM_xmlhttpRequest({
                                url,
                                context: _lastToken,
                                method: 'GET',
                                headers: (gisLayer.customHeaders) ? $.parseJSON(gisLayer.customHeaders): {},
                                onload(res2) {
                                    if (res2.status < 400) { // Handle stupid issue where http 4## is considered success
                                        processFeatures($.parseJSON(res2.responseText), res2.context, gisLayer);
                                    } else {
                                        logDebug(`HTTP request error: ${JSON.stringify(res2)}`);
                                        logError(`Could not fetch layer "${gisLayer.id}". Request returned ${res2.status}`);
                                        $(`label[for="gis-layer-${gisLayer.id}"]`).css({ color: '#ff0000' });
                                    }
                                },
                                onerror(res3) {
                                    logDebug(`xmlhttpRequest error:${JSON.stringify(res3)}`);
                                    logError(`Could not fetch layer "${gisLayer.id}". An error was thrown.`);
                                    $(`label[for="gis-layer-${gisLayer.id}"]`).css({ color: '#ff0000' });
                                }
                            });
                        });
                    }
                } else {
                    logDebug(`HTTP request error: ${JSON.stringify(res)}`);
                    logError(`Could not fetch counties from PY Census site.  Request returned ${res.status}`);
                }
            },
            onerror(res) {
                logDebug(`xmlhttpRequest error:${JSON.stringify(res)}`);
                logError('Could not fetch counties from PY Census site.  An error was thrown.');
            }
        });
    }

    function showScriptInfoAlert() {
        /* Check version and alert on update */
        if (ALERT_UPDATE && SCRIPT_VERSION !== _settings.lastVersion) {
            // alert(SCRIPT_VERSION_CHANGES);
            let releaseNotes = '';
            releaseNotes += '<p>What\'s New:</p>';
            if (SCRIPT_VERSION_CHANGES.length > 0) {
                releaseNotes += '<ul>';
                for (let idx = 0; idx < SCRIPT_VERSION_CHANGES.length; idx++)
                    releaseNotes += `<li>${SCRIPT_VERSION_CHANGES[idx]}`;
                releaseNotes += '</ul>';
            }
            else {
                releaseNotes += '<ul><li>Nothing major.</ul>';
            }
            WazeWrap.Interface.ShowScriptUpdate(GM_info.script.name, SCRIPT_VERSION, releaseNotes, GF_URL);
        }
    }

    function setEnabled(value) {
        _settings.enabled = value;
        saveSettingsToStorage();
        _mapLayer.setVisibility(value);
        _roadLayer.setVisibility(value);
        const color = value ? '#00bd00' : '#ccc';
        $('span#gis-layers-power-btn').css({ color });
        if (value) fetchFeatures();
        $('#layer-switcher-item_gis_layers').prop('checked', value);
    }

    function onGisLayerToggleChanged() {
        const checked = $(this).is(':checked');
        const layerId = $(this).data('layer-id');
        const idx = _settings.visibleLayers.indexOf(layerId);
        if (checked) {
            const gisLayer = _gisLayers.find(l => l.id === layerId);
            if (gisLayer.oneTimeAlert) {
                const lastAlertHash = _settings.oneTimeAlerts[layerId];
                const newAlertHash = hashString(gisLayer.oneTimeAlert);
                if (lastAlertHash !== newAlertHash) {
                    // alert(`Layer: ${gisLayer.name}\n\nMessage:\n${gisLayer.oneTimeAlert}`);
                    WazeWrap.Alerts.info(GM_info.script.name, `Layer: ${gisLayer.name}<br><br>Message:<br>${gisLayer.oneTimeAlert}`);
                    _settings.oneTimeAlerts[layerId] = newAlertHash;
                    saveSettingsToStorage();
                }
            }
            if (idx === -1) _settings.visibleLayers.push(layerId);
        } else if (idx > -1) _settings.visibleLayers.splice(idx, 1);
        if (!_ignoreFetch) {
            saveSettingsToStorage();
            fetchFeatures();
        }
    }

    function onOnlyShowApplicableLayersChanged() {
        _settings.onlyShowApplicableLayers = $(this).is(':checked');
        saveSettingsToStorage();
        fetchFeatures();
    }

    function onStateCheckChanged(evt) {
        const state = evt.data;
        const idx = _settings.selectedStates.indexOf(state);
        if (evt.target.checked) {
            if (idx === -1) _settings.selectedStates.push(state);
        } else if (idx > -1) _settings.selectedStates.splice(idx, 1);
        if (!_ignoreFetch) {
            saveSettingsToStorage();
            initLayersTab();
            fetchFeatures();
        }
    }

    function onLayerCheckboxChanged(checked) {
        setEnabled(checked);
    }

    function setFillParcels(doFill) {
        [LAYER_STYLES.parcels, LAYER_STYLES.state_parcels].forEach(style => {
            style.fillOpacity = doFill ? 0.2 : 0;
        });
    }

    function onFillParcelsCheckedChanged(evt) {
        const { checked } = evt.target;
        setFillParcels(checked);
        _settings.fillParcels = checked;
        saveSettingsToStorage();
        fetchFeatures();
    }

    function onMapMove() {
        if (_settings.enabled) fetchFeatures();
    }

    function onRefreshLayersClick() {
        const $btn = $('#gis-layers-refresh');
        if (!$btn.hasClass('fa-spin')) {
            $btn.css({ cursor: 'auto' });
            $btn.addClass('fa-spin');
            init(false);
        }
    }

    function onChevronClick(evt) {
        const $target = $(evt.currentTarget);
        $($target.children()[0])
            .toggleClass('fa fa-fw fa-chevron-down')
            .toggleClass('fa fa-fw fa-chevron-right');
        $($target.siblings()[0]).toggleClass('collapse');
    }

    function doToggleABunch(evt, checkState) {
        _ignoreFetch = true;
        $(evt.target).closest('fieldset').find('input').prop('checked', !checkState).trigger('click');
        _ignoreFetch = false;
        saveSettingsToStorage();
        if (evt.data) initLayersTab();
        fetchFeatures();
    }

    function onSelectAllClick(evt) {
        doToggleABunch(evt, true);
    }

    function onSelectNoneClick(evt) {
        doToggleABunch(evt, false);
    }

    function onGisAddrDisplayChange(evt) {
        _settings.addrLabelDisplay = evt.target.value;
        saveSettingsToStorage();
        fetchFeatures();
    }

    function onAddressDisplayShortcutKey() {
        if (!$('#gisAddrDisplay-hn').is(':checked')) {
            $('#gisAddrDisplay-hn').click();
        } else {
            $('#gisAddrDisplay-all').click();
        }
    }

    function initLayer() {
        const rules = _gisLayers.map(gisLayer => new OpenLayers.Rule({
            filter: new OpenLayers.Filter.Comparison({
                type: OpenLayers.Filter.Comparison.EQUAL_TO,
                property: 'layerID',
                value: gisLayer.id
            }),
            symbolizer: gisLayer.style
        }));

        setFillParcels(_settings.fillParcels);

        const style = new OpenLayers.Style(DEFAULT_STYLE, { rules });
        let existingLayer;
        let uniqueName;

        uniqueName = 'wmeGISLayersDefault';
        existingLayer = W.map.layers.find(l => l.uniqueName === uniqueName); // Note: W.map.getLayerByUniqueName(...) isn't working.
        if (existingLayer) W.map.removeLayer(existingLayer);
        _mapLayer = new OpenLayers.Layer.Vector('PY GIS Layers - Default', {
            uniqueName,
            styleMap: new OpenLayers.StyleMap(style)
        });

        uniqueName = 'wmeGISLayersRoads';
        existingLayer = W.map.layers.find(l => l.uniqueName === uniqueName); // Note: W.map.getLayerByUniqueName(...) isn't wworking.
        if (existingLayer) W.map.removeLayer(existingLayer);
        _roadLayer = new OpenLayers.Layer.Vector('PY GIS Layers - Roads', {
            uniqueName,
            styleMap: new OpenLayers.StyleMap(ROAD_STYLE)
        });

        _mapLayer.setVisibility(_settings.enabled);
        _roadLayer.setVisibility(_settings.enabled);

        W.map.addLayers([_roadLayer, _mapLayer]);
    } // END InitLayer

    function initLayersTab() {
        const user = W.loginManager.user.attributes.userName.toLowerCase();
        const states = _.uniq(_gisLayers.map(l => l.state)).filter(st => _settings.selectedStates.includes(st));

        $('#panel-gis-state-layers').empty().append(
            $('<div>', { class: 'controls-container' }).css({ 'padding-top': '0px' }).append(
                $('<input>', { type: 'checkbox', id: 'only-show-applicable-gis-layers' }).change(
                    onOnlyShowApplicableLayersChanged
                ).prop('checked', _settings.onlyShowApplicableLayers),
                $('<label>', { for: 'only-show-applicable-gis-layers' })
                    .css({ 'white-space': 'pre-line' }).text('Solo mostrar capas aplicables')
            ),
            $('.gis-layers-state-checkbox:checked').length === 0
                ? $('<div>').text('Marcar categoria de capas en solapa Configuraciones')
                : states.map(st => $('<fieldset>', {
                    id: `gis-layers-for-${st}`,
                    style: 'border:1px solid silver;padding:4px;border-radius:4px;-webkit-padding-before: 0;'
                }).append(
                    $('<legend>', { style: 'margin-bottom:0px;border-bottom-style:none;width:auto;' })
                        .click(onChevronClick).append(
                            $('<i>', {
                                class: 'fa fa-fw fa-chevron-down',
                                style: 'cursor: pointer;font-size: 12px;margin-right: 4px'
                            }),
                            $('<span>', {
                                style: 'font-size:14px;font-weight:600;text-transform: uppercase; cursor: pointer'
                            }).text(STATES.toFullName(st))
                        ),
                    $('<div>', { id: `${st}_body` }).append(
                        $('<div>').css({ 'font-size': '11px' }).append(
                            $('<span>').append(
                                'Select ',
                                $('<a>', { href: '#' })
                                    .text('Todos')
                                    .click(onSelectAllClick),
                                ' / ',
                                $('<a>', { href: '#' })
                                    .text('Ninguno')
                                    .click(onSelectNoneClick)
                            )
                        ),
                        $('<div>', { class: 'controls-container', style: 'padding-top:0px;' }).append(
                            _gisLayers.filter(l => (l.state === st && (!PRIVATE_LAYERS.hasOwnProperty(l.id)
                                || PRIVATE_LAYERS[l.id].includes(user))))
                                .map(gisLayer => {
                                    const id = `gis-layer-${gisLayer.id}`;
                                    return $('<div>', { class: 'controls-container', id: `${id}-container` })
                                        .css({ 'padding-top': '0px', display: 'block' })
                                        .append(
                                            $('<input>', { type: 'checkbox', id })
                                                .data('layer-id', gisLayer.id)
                                                .change(onGisLayerToggleChanged)
                                                .prop('checked', _settings.visibleLayers.includes(gisLayer.id)),
                                            $('<label>', { for: id, class: 'gis-state-layer-label' })
                                                .css({ 'white-space': 'pre-line' })
                                                .text(`${gisLayer.name}${gisLayer.restrictTo ? ' *' : ''}`)
                                                .attr('title', gisLayer.restrictTo ? `Restringido a: ${gisLayer.restrictTo}` : '')
                                                .contextmenu(evt => {
                                                    evt.preventDefault();
                                                    // TODO - enable the layer if it isn't already.
                                                    // Tried using click function on the evt target, but that didn't work.
                                                    _layerSettingsDialog.gisLayer = gisLayer;
                                                    _layerSettingsDialog.show();
                                                })
                                        );
                                })
                        )
                    )
                ))
        );
    }

    function initSettingsTab() {
        const states = _.uniq(_gisLayers.map(l => l.state));
        const createRadioBtn = (name, value, text, checked) => {
            const id = `${name}-${value}`;
            return [$('<input>', {
                type: 'radio', id, name, value
            }).prop('checked', checked), $('<label>', { for: id }).text(text).css({
                paddingLeft: '15px', marginRight: '4px'
            })];
        };
        $('#panel-gis-layers-settings').empty().append(
            $('<fieldset>', {
                style: 'border:1px solid silver;padding:8px;border-radius:4px;-webkit-padding-before: 0;margin-top:-8px;'
            }).append(
                $('<legend>', {
                    style: 'margin-bottom:0px;border-bottom-style:none;width:auto;'
                }).append($('<span>', {
                    style: 'font-size:14px;font-weight:600;text-transform: uppercase;'
                }).text('Etiquetas')),
                $('<div>', { id: 'labelSettings' }).append(
                    $('<div>', { class: 'controls-container' }).css({ 'padding-top': '2px' }).append(
                        $('<label>', { style: 'font-weight:normal;' }).text('Addresses:'),
                        createRadioBtn('gisAddrDisplay', 'hn', 'Nro Casa', _settings.addrLabelDisplay === 'hn'),
                        createRadioBtn('gisAddrDisplay', 'street', 'Calle', _settings.addrLabelDisplay === 'street'),
                        createRadioBtn('gisAddrDisplay', 'all', 'Ambos', _settings.addrLabelDisplay === 'all'),
                        createRadioBtn('gisAddrDisplay', 'none', 'None', _settings.addrLabelDisplay === 'none'),
                        $('<i>', {
                            class: 'waze-tooltip',
                            id: 'gisAddrDisplayInfo',
                            'data-toggle': 'tooltip',
                            style: 'margin-left:8px; font-size:12px',
                            'data-placement': 'bottom',
                            title: `This may not work properly for all layers. Please report issues to ${SCRIPT_AUTHOR}.`
                        }).tooltip()
                    )
                )
            ),
            $('<fieldset>', {
                style: 'border:1px solid silver;padding:8px;border-radius:4px;-webkit-padding-before: 0;'
            }).append(
                $('<legend>', {
                    style: 'margin-bottom:0px;border-bottom-style:none;width:auto;'
                }).append($('<span>', {
                    style: 'font-size:14px;font-weight:600;text-transform: uppercase;'
                }).text('Categoria de Capas')),
                $('<div>', { id: 'states_body' }).append(
                    $('<div>').css({ 'font-size': '11px' }).append(
                        $('<span>').append(
                            'Select ',
                            $('<a>', { href: '#' }).text('All').click(true, onSelectAllClick),
                            ' / ',
                            $('<a>', { href: '#' }).text('None').click(true, onSelectNoneClick)
                        )
                    ),
                    $('<div>', { class: 'controls-container', style: 'padding-top:0px;' }).append(
                        states.map(st => {
                            const fullName = STATES.toFullName(st);
                            const id = `gis-layer-enable-state-${st}`;
                            return $('<div>', { class: 'controls-container' })
                                .css({ 'padding-top': '0px', display: 'block' })
                                .append(
                                    $('<input>', { type: 'checkbox', id, class: 'gis-layers-state-checkbox' })
                                        .change(st, onStateCheckChanged)
                                        .prop('checked', _settings.selectedStates.includes(st)),
                                    $('<label>', { for: id }).css({ 'white-space': 'pre-line', color: '#777' }).text(fullName)
                                );
                        })
                    )
                )
            )
        );
        $('#panel-gis-layers-settings').append(
            $('<fieldset>', { style: 'border:1px solid silver;padding:8px;border-radius:4px;-webkit-padding-before: 0;' })
                .append(
                    $('<legend>', { style: 'margin-bottom:0px;border-bottom-style:none;width:auto;' })
                        .append(
                            $('<span>', { style: 'font-size:14px;font-weight:600;text-transform: uppercase;' })
                                .text('Apariencia')
                        ),
                    $('<div>', { class: 'controls-container' }).css({ 'padding-top': '2px' }).append(
                        $('<input>', { type: 'checkbox', id: 'fill-parcels' })
                            .change(onFillParcelsCheckedChanged)
                            .prop('checked', _settings.fillParcels),
                        $('<label>', { for: 'fill-parcels' }).css({ 'white-space': 'pre-line', color: '#777' }).text('Llenar parcelas')
                    )
                )
        );
        $('input[name=gisAddrDisplay]').change(onGisAddrDisplayChange);
    }

    async function initTab(firstCall = true) {
        if (firstCall) {
            const { user } = W.loginManager;
            const content = $('<div>').append(
                $('<span>', { style: 'font-size:14px;font-weight:600' }).text('Paraguay GIS Layers'),
                $('<span>', { style: 'font-size:11px;margin-left:10px;color:#aaa;' }).text(GM_info.script.version),
                // <a href="https://docs.google.com/forms/d/e/1FAIpQLSfMhBxF0P6bn8dFfOoNTAF1LHBFXr5w9oXvzqsii_TfA-_Bmw/viewform?usp=pp_url&entry.831784226=test" target="_blank" style="color: #6290b7;font-size: 12px;margin-left: 8px;" title="Report broken layers, bugs, request new layers, script features">Report an issue</a>
                $('<a>', {
                    href: REQUEST_FORM_URL.replace('{username}', user.userName),
                    target: '_blank',
                    style: 'color: #6290b7;font-size: 12px;margin-left: 8px;',
                    title: 'Reportar capas rotas, bugs, solicitar nuevas capas, nuevas caracteristicas'
                }).text('Enviar una solicitud'),
                $('<span>', {
                    id: 'gis-layers-refresh',
                    class: 'fa fa-refresh',
                    style: 'float: right;',
                    'data-toggle': 'tooltip',
                    title: 'Obtener nuevas informaciones del planilla primaria y refrescar todas las capas.'
                }),
                '<ul class="nav nav-tabs">'
                + '<li class="active"><a data-toggle="tab" href="#panel-gis-state-layers" aria-expanded="true">'
                + 'Capas'
                + '</a></li>'
                + '<li><a data-toggle="tab" href="#panel-gis-layers-settings" aria-expanded="true">'
                + 'Configuracion'
                + '</a></li> '
                + '</ul>',
                $('<div>', { class: 'tab-content', style: 'padding:8px;padding-top:2px' }).append(
                    $('<div>', { class: 'tab-pane active', id: 'panel-gis-state-layers', style: 'padding: 4px 0px 0px 0px; width: auto' }),
                    $('<div>', { class: 'tab-pane', id: 'panel-gis-layers-settings', style: 'padding: 4px 0px 0px 0px; width: auto' })
                )
            ).html();

            const powerButtonColor = _settings.enabled ? '#00bd00' : '#ccc';
            const labelText = $('<div>').append(
                $('<span>', {
                    class: 'fa fa-power-off',
                    id: 'gis-layers-power-btn',
                    style: `margin-right: 5px;cursor: pointer;color: ${powerButtonColor};font-size: 13px;`,
                    title: 'Activar/Desactivar Paraguay GIS Layers'
                }),
                $('<span>', { title: 'PY GIS Layers' }).text('PY GIS-L')
            ).html();

            const { tabLabel, tabPane } = W.userscripts.registerSidebarTab('PY GIS-L');
            tabLabel.innerHTML = labelText;
            tabPane.innerHTML = content;
            // Fix tab content div spacing.
            $(tabPane).parent().css({ width: 'auto', padding: '6px' });

            await W.userscripts.waitForElementConnected(tabPane);
            $('#gis-layers-power-btn').click(evt => {
                evt.stopPropagation();
                setEnabled(!_settings.enabled);
            });
            $('#gis-layers-refresh').click(onRefreshLayersClick);
        }

        initSettingsTab();
        initLayersTab();
    }

    function initGui(firstCall = true) {
        initLayer();

        if (firstCall) {
            initTab(true);

            WazeWrap.Interface.AddLayerCheckbox('Display', 'PY GIS Layers', _settings.enabled, onLayerCheckboxChanged);
            // W.map.events.register('moveend', null, onMapMove);
            WazeWrap.Events.register('moveend', null, onMapMove);
            showScriptInfoAlert();
        } else {
            initTab(firstCall);
        }
    }

    async function loadSpreadsheetAsync() {
        let data;
        try {
            data = await $.getJSON(`${LAYER_DEF_SPREADSHEET_URL}?key=${DEC(API_KEY)}`);
        } catch (err) {
            throw new Error(`Spreadsheet call failed. (${err.status}: ${err.statusText})`);
        }
        const [[minVersion], fieldNames, ...layerDefRows] = data.values;
        const REQUIRED_FIELD_NAMES = [
            'state', 'name', 'id', 'counties', 'url', 'where', 'labelFields',
            'processLabel', 'style', 'visibleAtZoom', 'labelsVisibleAtZoom', 'enabled',
            'restrictTo', 'oneTimeAlert', "areaToPoint", "isFeatureSet", "serverType"
        ];
        const result = { error: null };
        const checkFieldNames = fldName => fieldNames.includes(fldName);

        if (SCRIPT_VERSION < minVersion) {
            result.error = `Script must be updated to at least version ${
                minVersion} before layer definitions can be loaded.`;
        } else if (fieldNames.length < REQUIRED_FIELD_NAMES.length) {
            result.error = `Expected ${
                REQUIRED_FIELD_NAMES.length} columns in layer definition data.  Spreadsheet returned ${
                fieldNames.length}.`;
        } else if (!REQUIRED_FIELD_NAMES.every(fldName => checkFieldNames(fldName))) {
            result.error = 'Script expected to see the following column names in the layer '
                + `definition spreadsheet:\n${REQUIRED_FIELD_NAMES.join(', ')}\n`
                + `But the spreadsheet returned these:\n${fieldNames.join(', ')}`;
        }
        if (!result.error) {
            layerDefRows.filter(row => row.length).forEach(layerDefRow => {
                const layerDef = { enabled: '0' };
                fieldNames.forEach((fldName, fldIdx) => {
                    let value = layerDefRow[fldIdx];
                    if (value !== undefined && value.trim().length > 0) {
                        value = value.trim();
                        if (fldName === 'counties' || fldName === 'labelFields') {
                            value = value.split(',').map(item => item.trim());
                        } else if (fldName === 'processLabel') {
                            try {
                                // eslint-disable-next-line no-eval
                                value = eval(`(function(label, fieldValues){${value}})`);
                            } catch (ex) {
                                logError(`Error loading label processing function for layer "${
                                    layerDef.id}".`);
                                logDebug(ex);
                            }
                        } else if (fldName === 'style') {
                            layerDef.isRoadLayer = value === 'roads';
                            if (LAYER_STYLES.hasOwnProperty(value)) {
                                value = LAYER_STYLES[value];
                            } else if (!layerDef.isRoadLayer) {
                                // If style is not defined, try to read in as JSON (custom style)
                                try {
                                    value = JSON.parse(value);
                                } catch (ex) {
                                    // ignore error
                                }
                            }
                        } else if (fldName === 'state') {
                            value = value ? value.toUpperCase() : value;
                        } else if (fldName === 'restrictTo') {
                            try {
                                const { user } = W.loginManager;
                                const values = value.split(',').map(v => v.trim().toLowerCase());
                                layerDef.notAllowed = !values.some(entry => {
                                    const rankMatch = entry.match(/^r(\d)(\+am)?$/);
                                    if (rankMatch) {
                                        if (rankMatch[1] <= (user.attributes.rank + 1) && (!rankMatch[2] || user.attributes.isAreaManager)) {
                                            return true;
                                        }
                                    } else if (entry === 'am' && user.attributes.isAreaManager) {
                                        return true;
                                    } else if (entry === user.attributes.userName.toLowerCase()) {
                                        return true;
                                    }
                                    return false;
                                });
                            } catch (ex) {
                                logError(ex);
                            }
                        }
                        layerDef[fldName] = value;
                    } else if (fldName === 'labelFields') {
                        layerDef[fldName] = [''];
                    }
                });
                const enabled = layerDef.enabled && !['0', 'false', 'no', 'n'].includes(layerDef.enabled.toString().trim().toLowerCase());
                if (!layerDef.notAllowed && (enabled || layerDef.restrictTo)) {
                    _gisLayers.push(layerDef);
                }
            });
        }

        return result;
    }

    function loadScriptUpdateMonitor() {
        try {
            const updateMonitor = new WazeWrap.Alerts.ScriptUpdateMonitor(SCRIPT_NAME, SCRIPT_VERSION, DOWNLOAD_URL, GM_xmlhttpRequest);
            updateMonitor.start();
        } catch (ex) {
            // Report, but don't stop if ScriptUpdateMonitor fails.
            logError(ex);
        }
    }

    async function init(firstCall = true) {
        _gisLayers = [];
        if (firstCall) {
            loadScriptUpdateMonitor();
            initRoadStyle();
            loadSettingsFromStorage();
            installPathFollowingLabels();
            // W.accelerators.events.listeners was removed in WME beta, so check for it here before calling WazeWrap.Interface.Shortcut
            // Hopefully there will be a fix or workaround for this issue.
            if (W.accelerators.events.listeners) {
                new WazeWrap.Interface.Shortcut(
                    'GisLayersAddrDisplay',
                    'Activar/desactivar etiquetas/direcciones solo con numero casa (Paraguay GIS Layers)',
                    'layers',
                    'layersToggleGisAddressLabelDisplay',
                    _settings.toggleHnsOnlyShortcut,
                    onAddressDisplayShortcutKey,
                    null
                ).add();
            }
            window.addEventListener('beforeunload', saveSettingsToStorage, false);
            _layerSettingsDialog = new LayerSettingsDialog();
        }
        const t0 = performance.now();
        try {
            const result = await loadSpreadsheetAsync();
            if (result.error) {
                logError(result.error);
                return;
            }
            // _layerRefinements.forEach(layerRefinement => {
            //     const layerDef = _gisLayers.find(layerDef2 => layerDef2.id === layerRefinement.id);
            //     if (layerDef) {
            //         Object.keys(layerRefinement).forEach(fldName => {
            //             const value = layerRefinement[fldName];
            //             if (fldName !== 'id' && layerDef.hasOwnProperty(fldName)) {
            //                 logDebug(`The "${fldName}" property of layer "${
            //                     layerDef.id}" has a value hardcoded in the script, and also defined in the spreadsheet.`
            //                     + ' The spreadsheet value takes precedence.');
            //             } else if (value) layerDef[fldName] = value;
            //         });
            //     } else {
            //         logDebug(`Refined layer "${layerRefinement.id}" does not have a corresponding layer defined`
            //             + ' in the spreadsheet.  It can probably be removed from the script.');
            //     }
            // });
            logDebug(`Loaded ${_gisLayers.length} layer definitions in ${Math.round(performance.now() - t0)} ms.`);
            initGui(firstCall);
            fetchFeatures();
            $('#gis-layers-refresh').removeClass('fa-spin').css({ cursor: 'pointer' });
            logDebug('Inicializado.');
        } catch (err) {
            logError(err);
        }
    }

    function onWmeReady() {
        if (WazeWrap && WazeWrap.Ready) {
            logDebug('Inicializando...');
            init();
        } else {
            logDebug('Bootstrap ha fallado. Reintentando...');
            setTimeout(onWmeReady, 100);
        }
    }

    function bootstrap() {
        if (typeof W === 'object' && W.userscripts?.state.isReady) {
            onWmeReady();
        } else {
            document.addEventListener('wme-ready', onWmeReady, { once: true });
        }
    }

    bootstrap();

    /*eslint-disable*/
    function installPathFollowingLabels() {
        // Copyright (c) 2015 by Jean-Marc.Viglino [at]ign.fr
        // Dual-licensed under the CeCILL-B Licence (http://www.cecill.info/)
        // and the Beerware license (http://en.wikipedia.org/wiki/Beerware),
        // feel free to use and abuse it in your projects (the code, not the beer ;-).
        //
        //* Overwrite the SVG function to allow text along a path
        //*	setStyle function
        //*
        //*	Add new options to the Openlayers.Style

        // pathLabel: {String} Label to draw on the path
        // pathLabelXOffset: {String} Offset along the line to start drawing text in pixel or %, default: "50%"
        // pathLabelYOffset: {Number} Distance of the line to draw the text
        // pathLabelCurve: {String} Smooth the line the label is drawn on (empty string for no)
        // pathLabelReadable: {String} Make the label readable (empty string for no)

        // *	Extra standard values : all label and text values


        //  *
        //  * Method: removeChildById
        //  * Remove child in a node.
        //  *

        function removeChildById(node, id) {
            if (node.querySelector) {
                var c = node.querySelector('#' + id);
                if (c) node.removeChild(c);
                return;
            }
            // For old browsers
            var c = node.childNodes;
            if (c) for (var i = 0; i < c.length; i++) {
                if (c[i].id === id) {
                    node.removeChild(c[i]);
                    return;
                }
            }
        }


        //  *
        //  * Method: setStyle
        //  * Use to set all the style attributes to a SVG node.
        //  *
        //  * Takes care to adjust stroke width and point radius to be
        //  * resolution-relative
        //  *
        //  * Parameters:
        //  * node - {SVGDomElement} An SVG element to decorate
        //  * style - {Object}
        //  * options - {Object} Currently supported options include
        //  *                              'isFilled' {Boolean} and
        //  *                              'isStroked' {Boolean}

        var setStyle = OpenLayers.Renderer.SVG.prototype.setStyle;
        OpenLayers.Renderer.SVG.LABEL_STARTOFFSET = { 'l': '0%', 'r': '100%', 'm': '50%' };

        OpenLayers.Renderer.SVG.prototype.pathText = function (node, style, suffix) {
            var label = this.nodeFactory(null, 'text');
            label.setAttribute('id', node._featureId + '_' + suffix);
            if (style.fontColor) label.setAttributeNS(null, 'fill', style.fontColor);
            if (style.fontStrokeColor) label.setAttributeNS(null, 'stroke', style.fontStrokeColor);
            if (style.fontStrokeWidth) label.setAttributeNS(null, 'stroke-width', style.fontStrokeWidth);
            if (style.fontOpacity) label.setAttributeNS(null, 'opacity', style.fontOpacity);
            if (style.fontFamily) label.setAttributeNS(null, 'font-family', style.fontFamily);
            if (style.fontSize) label.setAttributeNS(null, 'font-size', style.fontSize);
            if (style.fontWeight) label.setAttributeNS(null, 'font-weight', style.fontWeight);
            if (style.fontStyle) label.setAttributeNS(null, 'font-style', style.fontStyle);
            if (style.labelSelect === true) {
                label.setAttributeNS(null, 'pointer-events', 'visible');
                label._featureId = node._featureId;
            } else {
                label.setAttributeNS(null, 'pointer-events', 'none');
            }

            function getpath(pathStr, readeable) {
                var npath = pathStr.split(',');
                var pts = [];
                if (!readeable || Number(npath[0]) - Number(npath[npath.length - 2]) < 0) {
                    while (npath.length) pts.push({ x: Number(npath.shift()), y: Number(npath.shift()) });
                } else {
                    while (npath.length) pts.unshift({ x: Number(npath.shift()), y: Number(npath.shift()) });
                }
                return pts;
            }

            var path = this.nodeFactory(null, 'path');
            var tpid = node._featureId + '_t' + suffix;
            var tpath = node.getAttribute('points');
            if (style.pathLabelCurve) {
                var pts = getpath(tpath, style.pathLabelReadable);
                var p = pts[0].x + ' ' + pts[0].y;
                var dx, dy, s1, s2;
                dx = (pts[0].x - pts[1].x) / 4;
                dy = (pts[0].y - pts[1].y) / 4;
                for (var i = 1; i < pts.length - 1; i++) {
                    p += ' C ' + (pts[i - 1].x - dx) + ' ' + (pts[i - 1].y - dy);
                    dx = (pts[i - 1].x - pts[i + 1].x) / 4;
                    dy = (pts[i - 1].y - pts[i + 1].y) / 4;
                    s1 = Math.sqrt(Math.pow(pts[i - 1].x - pts[i].x, 2) + Math.pow(pts[i - 1].y - pts[i].y, 2));
                    s2 = Math.sqrt(Math.pow(pts[i + 1].x - pts[i].x, 2) + Math.pow(pts[i + 1].y - pts[i].y, 2));
                    p += ' ' + (pts[i].x + s1 * dx / s2) + ' ' + (pts[i].y + s1 * dy / s2);
                    dx *= s2 / s1;
                    dy *= s2 / s1;
                    p += ' ' + pts[i].x + ' ' + pts[i].y;
                }
                p += ' C ' + (pts[i - 1].x - dx) + ' ' + (pts[i - 1].y - dy);
                dx = (pts[i - 1].x - pts[i].x) / 4;
                dy = (pts[i - 1].y - pts[i].y) / 4;
                p += ' ' + (pts[i].x + dx) + ' ' + (pts[i].y + dy);
                p += ' ' + pts[i].x + ' ' + pts[i].y;

                path.setAttribute('d', 'M ' + p);
            } else {
                if (style.pathLabelReadable) {
                    var pts = getpath(tpath, style.pathLabelReadable);
                    var p = '';
                    for (var i = 0; i < pts.length; i++) p += ' ' + pts[i].x + ' ' + pts[i].y;
                    path.setAttribute('d', 'M ' + p);
                } else path.setAttribute('d', 'M ' + tpath);
            }
            path.setAttribute('id', tpid);

            var defs = this.createDefs();
            removeChildById(defs, tpid);
            defs.appendChild(path);

            var textPath = this.nodeFactory(null, 'textPath');
            textPath.setAttributeNS('http://www.w3.org/1999/xlink', 'xlink:href', '#' + tpid);
            var align = style.labelAlign || OpenLayers.Renderer.defaultSymbolizer.labelAlign;
            label.setAttributeNS(null, 'text-anchor', OpenLayers.Renderer.SVG.LABEL_ALIGN[align[0]] || 'middle');
            textPath.setAttribute('startOffset', style.pathLabelXOffset || OpenLayers.Renderer.SVG.LABEL_STARTOFFSET[align[0]] || '50%');
            label.setAttributeNS(null, 'dominant-baseline', OpenLayers.Renderer.SVG.LABEL_ALIGN[align[1]] || 'central');
            if (style.pathLabelYOffset) label.setAttribute('dy', style.pathLabelYOffset);
            //textPath.setAttribute('method','stretch');
            //textPath.setAttribute('spacing','auto');

            textPath.textContent = style.pathLabel;
            label.appendChild(textPath);

            removeChildById(this.textRoot, node._featureId + '_' + suffix);
            this.textRoot.appendChild(label);
        };

        OpenLayers.Renderer.SVG.prototype.setStyle = function (node, style, options) {
            if (node._geometryClass === 'OpenLayers.Geometry.LineString' && style.pathLabel) {
                if (node._geometryClass === 'OpenLayers.Geometry.LineString' && style.pathLabel) {
                    var drawOutline = (!!style.labelOutlineWidth);
                    // First draw text in halo color and size and overlay the
                    // normal text afterwards
                    if (drawOutline) {
                        var outlineStyle = OpenLayers.Util.extend({}, style);
                        outlineStyle.fontColor = outlineStyle.labelOutlineColor;
                        outlineStyle.fontStrokeColor = outlineStyle.labelOutlineColor;
                        outlineStyle.fontStrokeWidth = style.labelOutlineWidth;
                        if (style.labelOutlineOpacity) outlineStyle.fontOpacity = style.labelOutlineOpacity;
                        delete outlineStyle.labelOutlineWidth;
                        this.pathText(node, outlineStyle, 'txtpath0');
                    }
                    this.pathText(node, style, 'txtpath');
                    setStyle.apply(this, arguments);
                }
            } else setStyle.apply(this, arguments);
            return node;
        };

        //  *
        //  * Method: drawGeometry
        //  * Remove the textpath if no geometry is drawn.
        //  *
        //  * Parameters:
        //  * geometry - {<OpenLayers.Geometry>}
        //  * style - {Object}
        //  * featureId - {String}
        //  *
        //  * Returns:
        //  * {Boolean} true if the geometry has been drawn completely; null if
        //  *     incomplete; false otherwise

        var drawGeometry = OpenLayers.Renderer.SVG.prototype.drawGeometry;
        OpenLayers.Renderer.SVG.prototype.drawGeometry = function (geometry, style, id) {
            var rendered = drawGeometry.apply(this, arguments);
            if (rendered === false) {
                removeChildById(this.textRoot, id + '_txtpath');
                removeChildById(this.textRoot, id + '_txtpath0');
            }
            return rendered;
        };

        // *
        // * Method: eraseGeometry
        // * Erase a geometry from the renderer. In the case of a multi-geometry,
        // *     we cycle through and recurse on ourselves. Otherwise, we look for a
        // *     node with the geometry.id, destroy its geometry, and remove it from
        // *     the DOM.
        // *
        // * Parameters:
        // * geometry - {<OpenLayers.Geometry>}
        // * featureId - {String}

        var eraseGeometry = OpenLayers.Renderer.SVG.prototype.eraseGeometry;
        OpenLayers.Renderer.SVG.prototype.eraseGeometry = function (geometry, featureId) {
            eraseGeometry.apply(this, arguments);
            removeChildById(this.textRoot, featureId + '_txtpath');
            removeChildById(this.textRoot, featureId + '_txtpath0');
        };

    }
})();