layer/layerRec/featureRecord.js

'use strict';

const attribFC = require('./attribFC.js')();
const placeholderFC = require('./placeholderFC.js')();
const attribRecord = require('./attribRecord.js')();
const layerInterface = require('./layerInterface.js')();
const shared = require('./shared.js')();

/**
 * @class FeatureRecord
 */
class FeatureRecord extends attribRecord.AttribRecord {

    /**
     * Create a layer record with the appropriate geoApi layer type.  Layer config
     * should be fully merged with all layer options defined (i.e. this constructor
     * will not apply any defaults).
     * @param {Object} layerClass    the ESRI api object for feature layers
     * @param {Object} esriRequest   the ESRI api object for making web requests with proxy support
     * @param {Object} apiRef        object pointing to the geoApi. allows us to call other geoApi functions.
     * @param {Object} config        layer config values
     * @param {Object} esriLayer     an optional pre-constructed layer
     * @param {Function} epsgLookup  an optional lookup function for EPSG codes (see geoService for signature)
     */
    constructor (layerClass, esriRequest, apiRef, config, esriLayer, epsgLookup) {
        super(layerClass, esriRequest, apiRef, config, esriLayer, epsgLookup);

        // handles placeholder symbol, possibly other things
        // if we were passed a pre-loaded layer, we skip this (it will run after the load triggers
        // in the super-constructor, thus overwriting our good results)
        if (!esriLayer || config.wfsConfig) {
            this._defaultFC = '0';
            this._featClasses['0'] = new placeholderFC.PlaceholderFC(this, this.name);
            this._fcount = undefined;
        }
    }

    get queryUrl () { return `${this.rootUrl}/${this._defaultFC}`; }

    /**
     * Creates an options object for the map API object
     *
     * @function makeLayerConfig
     * @returns {Object} an object with api options
     */
    makeLayerConfig () {
        const cfg = super.makeLayerConfig();
        cfg.mode = this.config.state.snapshot ? this._layerClass.MODE_SNAPSHOT
                                                        : this._layerClass.MODE_ONDEMAND;

        // if we have a definition at load, apply it here to avoid cancellation errors on
        if (this.config.initialFilteredQuery) {
            cfg.definitionExpression = this.config.initialFilteredQuery;
        }

        // TODO confirm this logic. old code mapped .options.snapshot.value to the button -- meaning if we were in snapshot mode,
        //      we would want the button disabled. in the refactor, the button may get it's enabled/disabled from a different source.
        // this.config.state.snapshot = !this.config.state.snapshot;
        this._snapshot = this.config.state.snapshot;

        return cfg;
    }

    /**
     * Indicates the geometry type of the layer.
     *
     * @function getGeomType
     * @returns {String} the geometry type of the layer
     */
    getGeomType () {
        return this._featClasses[this._defaultFC].geomType;
    }

    /**
     * Indicates the oid field of the layer.
     *
     * @function getOidField
     * @returns {String} the oid field of the layer
     */
    getOidField () {
        return this._featClasses[this._defaultFC].oidField;
    }

    /**
     * Provides the proxy interface object to the layer.
     *
     * @function getProxy
     * @returns {Object} the proxy interface for the layer
     */
    getProxy () {
        if (!this._rootProxy) {
            this._rootProxy = new layerInterface.LayerInterface(this);
            this._rootProxy.convertToFeatureLayer(this);
        }
        return this._rootProxy;
    }

    /**
     * Triggers when the layer loads.
     *
     * @function onLoad
     */
    onLoad () {
        const loadPromises = super.onLoad();

        // we run into a lot of funny business with functions/constructors modifying parameters.
        // this essentially clones an object to protect original objects against trickery.
        const jsonCloner = inputObject => {
            return JSON.parse(JSON.stringify(inputObject));
        };

        // attempt to set custom renderer here. if fails, we can attempt on client but prefer it here
        // as this doesnt care where the layer came from
        if (this.config.customRenderer.type) {
            // all renderers have a type field. if it's missing, no renderer was provided, or its garbage
            const classMapper = {
                simple: this._apiRef.symbology.SimpleRenderer,
                classBreaks: this._apiRef.symbology.ClassBreaksRenderer,
                uniqueValue: this._apiRef.symbology.UniqueValueRenderer
            }

            // renderer constructors apparently convert their input json from server style to client style.
            // we dont want that. use a clone to protect config's property.
            const cloneRenderer = jsonCloner(this.config.customRenderer);
            const custRend = classMapper[cloneRenderer.type](cloneRenderer);
            this._layer.setRenderer(custRend);
        }

        // get attribute package
        let attribPackage;
        let featIdx;
        if (this.dataSource() !== shared.dataSources.ESRI) {
            featIdx = '0';
            attribPackage = this._apiRef.attribs.loadFileAttribs(this._layer);
        } else {
            const splitUrl = shared.parseUrlIndex(this._layer.url);
            featIdx = splitUrl.index;
            this.rootUrl = splitUrl.rootUrl;

            // methods in the attrib loader will update our copy of the renderer. if we pass in the config reference, it gets
            // updated and some weird stuff happens. Make a copy.
            const cloneRenderer = jsonCloner(this.config.customRenderer);
            attribPackage = this._apiRef.attribs.loadServerAttribs(splitUrl.rootUrl, featIdx, this.config.outfields,
                cloneRenderer);
        }

        // feature has only one layer
        const aFC = new attribFC.AttribFC(this, featIdx, attribPackage, this.config);
        this._defaultFC = featIdx;
        this._featClasses[featIdx] = aFC;

        const pLS = aFC.loadSymbology();

        // update asynch data
        const pLD = aFC.getLayerData().then(ld => {
            aFC.geomType = ld.geometryType;
            aFC.oidField = ld.oidField;
            aFC.nameField = this.config.nameField || ld.nameField || '';
            aFC.tooltipField = this.config.tooltipField || aFC.nameField;

            // trickery. file layer can have field names that are bad keys.
            // our file loader will have corrected them, but config.nameField and config.tooltipField will have
            // been supplied from the wizard (it pre-fetches fields to present a choice
            // to the user). If the nameField / tooltipField was adjusted for bad characters, we need to
            // re-synchronize it here.
            if (this.dataSource() !== shared.dataSources.ESRI) {
                if (ld.fields.findIndex(f => f.name === aFC.nameField) === -1) {
                    const validField = ld.fields.find(f => f.alias === aFC.nameField);
                    if (validField) {
                        aFC.nameField = validField.name;
                        if (!this.config.tooltipField) {    // tooltipField wasn't explicitly provided, so it was also using the bad nameField key
                            afc.tooltipField = validField.name
                        }
                    } else {
                        // give warning. impact is tooltips will have no text, details pane no header
                        console.warn(`Cannot find name field in layer field list: ${aFC.nameField}`);
                    }
                }

                // only check the tooltipField if it was provided from the config, otherwise it would have been corrected above already (if required)
                if (this.config.tooltipField && ld.fields.findIndex(f => f.name === aFC.tooltipField) === -1) {
                    const validField = ld.fields.find(f => f.alias === aFC.tooltipField);
                    if (validField) {
                        aFC.tooltipField = validField.name;
                    } else {
                        // give warning. impact is tooltips will have no text, details pane no header
                        console.warn(`Cannot find name field in layer field list: ${aFC.tooltipField}`);
                    }
                }
            }
        });

        const pFC = this.getFeatureCount().then(fc => {
            this._fcount = fc;
        });

        // if file based (or server extent was fried), calculate extent based on geometry
        if (!this.extent || !this.extent.xmin) {
            this.extent = this._apiRef.proj.graphicsUtils.graphicsExtent(this._layer.graphics);
        }

        loadPromises.push(pLD, pFC, pLS);
        Promise.all(loadPromises).then(() => {
            this._stateChange(shared.states.LOADED);
        });
    }

    /**
     * Get feature count of this layer.
     *
     * @function getFeatureCount
     * @return {Promise}       resolves with an integer indicating the feature count.
     */
    getFeatureCount () {
        // just use the layer url (or lack of in case of file layer)
        return super.getFeatureCount(this._layer.url);
    }

    /**
     * Indicates if the layer is file based, WFS, or esri based.
     *
     * @function dataSource
     * @returns {String} 'file' if file layer, 'wfs' if WFS, else 'esri'
     */
    dataSource () {
        // 'this.layerType' will be 'esriFeature' even for WFS layers, so must use 'this.config.layerType'
        if (this.config.layerType === shared.clientLayerType.OGC_WFS) {
            return shared.dataSources.WFS;
        } else if (this._layer && !this._layer.url) {   // TODO revisit.  is it robust enough?
            return shared.dataSources.FILE;
        } else {
            return shared.dataSources.ESRI;
        }
    }

    /**
     * Attempts to abort an attribute load in progress.
     * Harmless to call before or after an attribute load.
     *
     * @function abortAttribLoad
     */
    abortAttribLoad () {
        this._featClasses[this._defaultFC].abortAttribLoad();
    }

    // TODO determine who is setting this. if we have an internal
    //      snapshot process, it might become a read-only property
    get isSnapshot () { return this._snapshot; }
    set isSnapshot (value) { this._snapshot = value; }

    get layerType () { return shared.clientLayerType.ESRI_FEATURE; }

    get featureCount () { return this._fcount; }

    get loadedFeatureCount () { return this._featClasses[this._defaultFC].loadedFeatureCount; }

    get filter () { return this._featClasses[this._defaultFC].filter; }

    /**
     * Triggers when the mouse enters a feature of the layer.
     *
     * @function onMouseOver
     * @param {Object} standard mouse event object
     */
    onMouseOver (e) {
        if (this._hoverEvent.listenerCount > 0) {

            const showBundle = {
                type: 'mouseOver',
                point: e.screenPoint,
                target: e.target
            };

            // tell anyone listening we moused into something
            this._hoverEvent.fireEvent(showBundle);

            // pull metadata for this layer.
            let oid;
            this.getLayerData().then(lInfo => {
                // graphic attributes will only have the OID if layer is server based
                oid = e.graphic.attributes[lInfo.oidField];
                const graphicPromise = this.fetchGraphic(oid, { attribs: true });
                return Promise.all([Promise.resolve(lInfo), graphicPromise]);
            }).then(([lInfo, graphicBundle]) => {

                const featAttribs = graphicBundle.graphic.attributes;

                // get icon via renderer and geoApi call
                const svgcode = this._apiRef.symbology.getGraphicIcon(featAttribs, lInfo.renderer);

                // duplicate the position so listener can verify this event is same as mouseOver event above
                const loadBundle = {
                    type: 'tipLoaded',
                    name: this.getTooltipName(oid, featAttribs),
                    attribs: featAttribs,
                    target: e.target,
                    svgcode
                };

                // tell anyone listening we moused into something
                this._hoverEvent.fireEvent(loadBundle);

            });
        }
    }

    /**
     * Triggers when the mouse leaves a feature of the layer.
     *
     * @function onMouseOut
     * @param {Object} standard mouse event object
     */
    onMouseOut (e) {
        // tell anyone listening we moused out
        const outBundle = {
            type: 'mouseOut',
            target: e.target
        };
        this._hoverEvent.fireEvent(outBundle);
    }

    /**
     * Run a query on a feature layer, return the result as a promise.
     * Options:
     * - clickEvent {Object} an event object from the mouse click event, where the user wants to identify.
     * - map {Object}        map object. A geoApi wrapper, such as esriMap, not an actual esri api map
     * - geometry {Object}   geometry (in map coordinates) to identify against
     * - tolerance {Integer} an optional click tolerance for the identify
     *
     * @function identify
     * @param {Object} opts    additional arguemets, see above.
     * @returns {Object} an object with identify results array and identify promise resolving when identify is complete; if an empty object is returned, it will be skipped
     */
    identify (opts) {
        // TODO add full documentation for options parameter

        // early kickout check. not loaded/error; not visible; not queryable; off scale
        if (!shared.layerLoaded(this.state) ||
            !this.visibility ||
            !this.isQueryable() ||
            this.isOffScale(opts.map.getScale()).offScale) {

            // TODO verifiy this is correct result format if layer should be excluded from the identify process
            return { identifyResults: [], identifyPromise: Promise.resolve() };
        }

        const identifyResult = new shared.IdentifyResult(this.getProxy());
        const tolerance = opts.tolerance || this.clickTolerance;

        // run a spatial query
        const qry = new this._apiRef.layer.Query();
        qry.outFields = ['*']; // this will result in just objectid fields, as that is all we have in feature layers

        // more accurate results without making the buffer if we're dealing with extents
        // polygons from added file need buffer
        // TODO further investigate why esri is requiring buffer for file-based polygons. logic says it shouldnt
        if (this.getGeomType() === 'esriGeometryPolygon' && this.dataSource() === shared.dataSources.ESRI) {
            qry.geometry = opts.geometry;
        } else {
            // TODO investigate why we are using opts.clickEvent.mapPoint and not opts.geometry
            qry.geometry = this.makeClickBuffer(opts.clickEvent.mapPoint, opts.map, tolerance);
        }

        // TODO possible efficiency boost.  change logic to not force the getAttribs() call.
        //      instead, recognize if attribs are not loaded, and use the fast-cache attrib load
        //      instead (similar to what a hovertip does)
        const identifyPromise = Promise.all([
                this.getAttribs(),
                Promise.resolve(this._layer.queryFeatures(qry)),
                this.getLayerData()
            ])
            .then(([attributes, queryResult, layerData]) => {
                // transform attributes of query results into {name,data} objects one object per queried feature
                //
                // each feature will have its attributes converted into a table
                // placeholder for now until we figure out how to signal the panel that
                // we want to make a nice table

                let validResults;
                if (this.dataSource() === shared.dataSources.ESRI) {
                    // because server-sourced layers use definition expression, the results are automatically filtered
                    validResults = queryResult.features;
                } else {
                    // file / wfs
                    // the query will return items that are invisible due to filters. banish them.
                    validResults = queryResult.features.filter(f => {
                        const objId = f.attributes[layerData.oidField];
                        const graphic = this._layer.graphics.find(g => {
                            return g.attributes[layerData.oidField] === objId;
                        });
                        if (graphic) {
                            return graphic.visible;
                        } else {
                            // couldn't find this graphic. should never happen
                            return false;
                        }
                    });
                }
                identifyResult.isLoading = false;
                identifyResult.data = validResults.map(
                    feat => {
                        // grab the object id of the feature we clicked on.
                        const objId = feat.attributes[layerData.oidField];
                        const objIdStr = objId.toString();

                        // use object id find location of our feature in the feature array, and grab its attributes
                        const featAttribs = attributes.features[attributes.oidIndex[objIdStr]].attributes;
                        return {
                            name: this.getFeatureName(objIdStr, featAttribs),
                            data: this.attributesToDetails(featAttribs, layerData.fields),
                            oid: objId,
                            symbology: [
                                { svgcode: this._apiRef.symbology.getGraphicIcon(featAttribs, layerData.renderer) }
                            ]
                        };
                    });
            });

        return { identifyResults: [identifyResult], identifyPromise };
    }

    /**
     * Applies a definition query to the layer.
     *
     * @function setDefinitionQuery
     * @param {String} query a valid definition query
     */
    setDefinitionQuery (query) {
        // very difficult.
        this._layer.setDefinitionExpression(query);
    }

    /**
     * Hot swaps the core underlying layer of this record.
     * THIS IS VERY VERY BAD.
     * Doing this as a workaround to our fundamental WFS loading problem.
     *
     * @function updateWfsSource
     * @param {Object} esriLayer an esri layer. specifically a FeatureLayer that's been pre-created from GeoJSON source
     */
    updateWfsSource (esriLayer) {
        this._layer = esriLayer;
        this.bindEvents(this._layer);
        this._snapshot = true; // possibly redundant

        // do the loading activites.  will trigger the loaded event.
        this.onLoad();
    }

}

module.exports = () => ({
    FeatureRecord
});