layer/ogc.js

'use strict';
const yxList = require('./reversedAxis.json');

function getFeatureInfoBuilder(esriBundle) {
    /**
     * Handles click events for WMS layers (makes a WMS GetFeatureInfo call behind the scenes).
     *
     * @param {WMSLayer} wmsLayer an ESRI WMSLayer object to be queried
     * @param {Object} clickEvent an ESRI map click event (used for screen coordinates)
     * @param {Array} layerList a list of strings identifying the WMS layers to be queried
     * @param {String} mimeType the format to be requested for the response
     * @returns {Promise} a promise which resolves with the raw text of the GetFeatureInfo response
     */
    return (wmsLayer, clickEvent, layerList, mimeType) => {
        let wkid;
        let req;
        const esriMap = wmsLayer.getMap();
        const ext = esriMap.extent;
        const srList = wmsLayer.spatialReferences;
        const layers = layerList.join(',');

        // tear off any decimals from the screenpoint coords.
        const intX = parseInt(clickEvent.screenPoint.x);
        const intY = parseInt(clickEvent.screenPoint.y);

        // result return type is text unless we have a fancy case
        const customReturnType = {
            'application/json': 'json'
        };

        const returnType = customReturnType[mimeType] || 'text';

        if (srList && srList.length > 1) {
            wkid = srList[0];
        } else if (esriMap.spatialReference.wkid) {
            wkid = esriMap.spatialReference.wkid;
        } else {
            console.error('No valid wkid/epsg code found for WMS service. Identify request will likely fail.')
        }
        if (wmsLayer.version === '1.3' || wmsLayer.version === '1.3.0') {
            req = { CRS: 'EPSG:' + wkid, I: intX, J: intY, STYLES: '', FORMAT: wmsLayer.imageFormat };
            if (yxList.indexOf(String(wkid)) > -1) {
                req.BBOX = `${ext.ymin},${ext.xmin},${ext.ymax},${ext.xmax}`;
            }
        } else {
            req = { SRS: 'EPSG:' + wkid, X: intX, Y: intY };
        }
        if (!req.hasOwnProperty('BBOX')) {
            req.BBOX = `${ext.xmin},${ext.ymin},${ext.xmax},${ext.ymax}`;
        }
        const settings = {
            SERVICE: 'WMS',
            REQUEST: 'GetFeatureInfo',
            VERSION: wmsLayer.version,
            WIDTH: esriMap.width,
            HEIGHT: esriMap.height,
            QUERY_LAYERS: layers,
            LAYERS: layers,
            INFO_FORMAT: mimeType
        };

        // apply any custom parameters (ignore styles for the moment)
        if (wmsLayer.customLayerParameters) {
            Object.keys(wmsLayer.customLayerParameters).forEach(key => {
                if (key.toLowerCase() !== 'styles') {
                    settings[key] = wmsLayer.customLayerParameters[key];
                }
            });
        }

        Object.keys(settings).forEach(key => req[key] = settings[key]);

        return Promise.resolve(esriBundle.esriRequest({
            url: wmsLayer.url.split('?')[0],
            content: req,
            handleAs: returnType
        }));
    };
}

function parseCapabilitiesBuilder(esriBundle) {

    const query = esriBundle.dojoQuery;

    /**
     * Fetch layer data from a WMS endpoint.  This method will execute a WMS GetCapabilities
     * request against the specified URL, it requests WMS 1.3 and it is capable of parsing
     * 1.3 or 1.1.1 responses.  It returns a promise which will resolve with basic layer
     * metadata and querying information.
     *
     * metadata response format:
     *   { queryTypes: [mimeType(str)],
     *     layers: [
     *       {name(str), desc(str), queryable(bool), layers:[recursive] }
     *     ] }
     *
     * @param {string} wmsEndpoint a URL pointing to a WMS server (it must not include a query string)
     * @return {Promise} a promise resolving with a metadata object (as specified above)
     */
    return (wmsEndpoint) => {
        const reqPromise = new Promise(resolve => {
            getCapabilities()
                .then(data => resolve(data)) // if successful, pass straight back
                .catch(() => { // if errors, try again; see fgpv-vpgf/fgpv-vpgf#908 issue
                    console.error('Get capabilities failed; trying the second time;');
                    resolve(getCapabilities());
                });
        });

        // there might already be a way to do this in the parsing API
        // I don't know XML parsing well enough (and I don't want to)
        // this has now been ported from RAMP to FGPV and I still, happily,
        // do not know any more about XML parsing now
        function getImmediateChild(node, childName) {
            for (let i = 0; i < node.childNodes.length; ++i) {
                if (node.childNodes[i].nodeName === childName) {
                    return node.childNodes[i];
                }
            }
            return undefined;
        }

        function getImmediateChildren(node, childName) {
            let children = [];
            for (let i = 0; i < node.childNodes.length; ++i) {
                if (node.childNodes[i].nodeName === childName) {
                    children.push(node.childNodes[i]);
                }
            }
            return children;
        }

        // find all <Layer> nodes under the given XML node
        // pick title, name and queryable nodes/attributes
        // also have a list of all styles and the current style
        // recursively called on all child <Layer> nodes
        function getLayers(xmlNode) {
            if (! xmlNode) {
                return [];
            }
            return query('> Layer', xmlNode).map(layer => {
                const nameNode = getImmediateChild(layer, 'Name');
                const titleNode = getImmediateChild(layer, 'Title');

                const allStyles = [];
                const styleToURL = {};
                const styles = getImmediateChildren(layer, 'Style');
                styles.forEach(style => {
                    const name = getImmediateChild(style, 'Name').textContent;
                    allStyles.push(name);

                    const legendURL = getImmediateChild(style, 'LegendURL');
                    if (legendURL) {
                        const url = getImmediateChild(legendURL, 'OnlineResource').getAttribute('xlink:href');
                        styleToURL[name] = url;
                    }
                });

                return {
                    name: nameNode ? nameNode.textContent : null,
                    desc: titleNode.textContent,
                    queryable: layer.getAttribute('queryable') === '1',
                    layers: getLayers(layer),
                    allStyles: allStyles,
                    styleToURL, styleToURL,
                    currentStyle: allStyles[0]
                };
            });
        }

        function getCapabilities() {
            let url = wmsEndpoint;

            // if url has a '?' do not append to avoid errors, user must add this manually
            if (wmsEndpoint.indexOf('?') === -1) {
                url += '?service=WMS&version=1.3&request=GetCapabilities';
            }

            return Promise.resolve(new esriBundle.esriRequest({
                url,
                handleAs: 'xml'
            }).promise);
        }

        return reqPromise.then(data => ({
            layers: getLayers(query('Capability', data)[0]),
            queryTypes: query('GetFeatureInfo > Format', data).map(node => node.textContent)
        }));
    };

}

/**
 * Recursively crawl a wms layer info structure. Store any legends in the provided map object.
 *
 * @private
 * @param {Array} layerInfos array of ESRI WMSLayerInfo objects
 * @param {Map} urlMap a Map of sublayer names to legend urls
 */
function crawlLayerInfos(layerInfos, urlMap) {
    layerInfos.forEach(li => {
        if (li.name) {
            urlMap.set(li.name, li.legendURL);
        }
        if (li.subLayers.length > 0) {
            crawlLayerInfos(li.subLayers, urlMap);
        }
    });
}

/**
 * Finds the appropriate legend URLs for WMS layers.
 *
 * @param {WMSLayer} wmsLayer an ESRI WMSLayer object to be queried
 * @param {Array} layerList a list of objects identifying the WMS layers to be queried
 * @returns {Array} a list of strings containing URLs for specified layers (order is preserved)
 */
function getLegendUrls(wmsLayer, layerList) {
    const liMap = new Map();
    crawlLayerInfos(wmsLayer.layerInfos, liMap);

    const legendURLs = layerList.map(l =>
        typeof l.styleToURL !== 'undefined' ? l.styleToURL[l.currentStyle] : undefined
    );
    legendURLs.forEach((entry, index) => {
        if (!entry) {
            legendURLs[index] = liMap.get(layerList[index].id)
        }
    });

    return legendURLs;
}

module.exports = esriBundle => {
    return {
        WmsLayer: esriBundle.WmsLayer,
        getFeatureInfo: getFeatureInfoBuilder(esriBundle),
        parseCapabilities: parseCapabilitiesBuilder(esriBundle),
        getLegendUrls
    };
};