layer/layerRec/attribFC.js

'use strict';

const shared = require('./shared.js')();
const basicFC = require('./basicFC.js')();
const filter = require('./filter.js')();

/**
 * @class AttribFC
 */
class AttribFC extends basicFC.BasicFC {
    // attribute-specific variant for feature class object.
    // deals with stuff specific to a feature class that has attributes

    /**
     * Create an attribute specific feature class object
     * @param {Object} parent        the Record object that this Feature Class belongs to
     * @param {String} idx           the service index of this Feature Class. an integer in string format. use '0' for non-indexed sources.
     * @param {Object} layerPackage  a layer package object from the attribute module for this feature class
     * @param {Object} config        the config object for this sublayer
     */
    constructor (parent, idx, layerPackage, config) {
        super(parent, idx, config);

        this._layerPackage = layerPackage;
        this._geometryType = undefined; // this indicates unknown to the ui.
        this._oidField = undefined;
        this._fcount = undefined;
        this._quickCache = {
            attribs: {},
            geoms: {}
        };
        this.filter = new filter.Filter(this);
    }

    get geomType () { return this._geometryType; }
    set geomType (value) { this._geometryType = value; }

    get oidField () { return this._oidField; }
    set oidField (value) { this._oidField = value; }

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

    // basically an identifier object for this FC. 
    // TODO maybe consider using the id on the esri layer? to handle cases where no id provided / collision of ids?
    get fcID () {
        return {
            layerId: this._parent.initialConfig.id,
            layerIdx: this._idx
        };
    }

    get loadedFeatureCount () { return this._layerPackage ? this._layerPackage.loadedFeatureCount : 0; }

    /**
     * Returns attribute data for this FC.
     *
     * @function getAttribs
     * @returns {Promise}         resolves with a layer attribute data object
     */
    getAttribs () {
        const attribsDownloaded = this.attribsLoaded();

        const attribPromise = this._layerPackage.getAttribs();

        attribPromise.then(attrib => {
            // only trigger the event the first time when the download was in progress.
            // after the attribs have been downloaded, if triggered again through API, since the attributes have
            // previously been downloaded, this event will not trigger in the viewer
            if (!attribsDownloaded) {
                this._parent._attribsAdded(this._idx, attrib.features);

                // for file layers, since attributes are local and we have promise initially,
                // must set loadIsDone to true after promise resolved to ensure we trigger event once and only once
                if (this._parent.dataSource() !== shared.dataSources.ESRI) {
                    this._layerPackage.loadIsDone = true;
                }
            }
        });

        return attribPromise;
    }

    /**
     * Indicates if attributes have been downloaded for this FC.
     *
     * @function attribsLoaded
     * @returns {Boolean}         true if attributes are downloaded.
     */
    attribsLoaded () {
        return this._layerPackage.loadIsDone;
    }

    /**
     * Returns layer-specific data for this FC.
     *
     * @function getLayerData
     * @returns {Promise}         resolves with a layer data object
     */
    getLayerData () {
        return this._layerPackage.layerData;
    }

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

    /**
     * Download or refresh the internal symbology for the FC.
     *
     * @function loadSymbology
     * @returns {Promise}         resolves when symbology has been downloaded
     */
    loadSymbology () {
        return this.getLayerData().then(lData => {
            if (lData.layerType === 'Feature Layer') {
                // feature always has a single item, so index 0
                this.symbology = shared.makeSymbologyArray(lData.legend.layers[0].legend);
            } else {
                // non-feature source. use legend server
                return super.loadSymbology();
            }
        });
    }

    /**
     * Extract the feature name from a feature as best we can.
     *
     * @function getFeatureName
     * @param {String} objId      the object id of the attribute
     * @param {Object} attribs    the dictionary of attributes for the feature.
     * @returns {String}          the name of the feature
     */
    getFeatureName (objId, attribs) {
        // TODO revisit the objId parameter.  Do we actually need this fallback anymore?
        // NOTE: we used to have fallback logic here that would use layer settings
        //       if this.nameField had no value. Logic has changed to now push
        //       layer settings to this.nameField during the load event of the
        //       Record.

        if (this.nameField && attribs) {
            // extract name
            return attribs[this.nameField];
        } else {
            // FIXME wire in "feature" to translation service
            return 'Feature ' + objId;
        }
    }

    /**
     * Extract the tooltip field from a feature as best we can.
     *
     * @function getTooltipName
     * @param {String} objId      the object id of the attribute
     * @param {Object} attribs    the dictionary of attributes for the feature.
     * @returns {String}          the name of the feature
     */
    getTooltipName (objId, attribs) {
        // TODO revisit the objId parameter.  Do we actually need this fallback anymore?
        // NOTE: we used to have fallback logic here that would use layer settings
        //       if this.nameField had no value. Logic has changed to now push
        //       layer settings to this.nameField during the load event of the
        //       Record.

        if (this.tooltipField && attribs) {
            // extract name
            return attribs[this.tooltipField];
        } else {
            // FIXME wire in "feature" to translation service
            return 'Feature ' + objId;
        }
    }

    /**
     * Retrieves attributes from a layer for a specified feature index
     * @return {Promise}            promise resolving with formatted attributes to be consumed by the datagrid and esri feature identify
     */
    getFormattedAttributes () {
        if (this._formattedAttributes) {
            return this._formattedAttributes;
        }

        // TODO after refactor, consider changing this to a warning and just return some dummy value
        if (this.layerType === shared.clientLayerType.ESRI_RASTER) {
            throw new Error('Attempting to get attributes on a raster layer.');
        }

        this._formattedAttributes = Promise.all([this.getAttribs(), this.getLayerData()])
            .then(([aData, lData]) => {
                // create columns array consumable by datables
                const columns = lData.fields
                    .filter(field =>

                        // assuming there is at least one attribute - empty attribute budnle promises should be rejected, so it never even gets this far
                        // filter out fields where there is no corresponding attribute data
                        aData.features[0].attributes.hasOwnProperty(field.name))
                    .map(field => ({
                        data: field.name,
                        title: field.alias || field.name
                    }));

                // derive the icon for the row
                const rows = aData.features.map(feature => {
                    const att = feature.attributes;
                    att.rvInteractive = '';
                    att.rvSymbol = this._parent._apiRef.symbology.getGraphicIcon(att, lData.renderer);
                    return att;
                });

                // if a field name resembles a function, the data table will treat it as one.
                // to get around this, we add a function with the same name that returns the value,
                // tricking that silly datagrid.
                columns.forEach(c => {
                    if (c.data.substr(-2) === '()') {
                        // have to use function() to get .this to reference the row.
                        // arrow notation will reference the attribFC class.
                        const secretFunc = function() {
                            return this[c.data];
                        };

                        const stub = c.data.substr(0, c.data.length - 2); // function without brackets
                        rows.forEach(r => {
                            r[stub] = secretFunc;
                        });
                    }
                });

                return {
                    columns,
                    rows,
                    fields: lData.fields, // keep fields for reference ...
                    oidField: lData.oidField, // ... keep a reference to id field ...
                    oidIndex: aData.oidIndex, // ... and keep id mapping array
                    renderer: lData.renderer
                };
            })
            .catch(e => {
                delete this._formattedAttributes; // delete cached promise when the geoApi `getAttribs` call fails, so it will be requested again next time `getAttributes` is called;
                if (e === 'ABORTED') {
                    throw new Error('ABORTED');
                } else {
                    throw new Error('Attrib loading failed');
                }
            });

        return this._formattedAttributes;
    }

    /**
     * Check to see if the attribute in question is an esriFieldTypeDate type.
     *
     * @param {String} attribName     the attribute name we want to check if it's a date or not
     * @return {Promise}              resolves to true or false based on the attribName type being esriFieldTypeDate
     */
    checkDateType (attribName) {
        // TEST STATUS none
        // grab attribute info (waiting for it it finish loading)
        return this.getLayerData().then(lData => {
            // inspect attribute fields
            if (lData.fields) {
                const attribField = lData.fields.find(field => {
                    return field.name === attribName;
                });
                if (attribField && attribField.type) {
                    return attribField.type === 'esriFieldTypeDate';
                }
            }
            return false;
        });
    }

    /**
     * Get the best user-friendly name of a field. Uses alias if alias is defined, else uses the system attribute name.
     *
     * @param {String} attribName     the attribute name we want a nice name for
     * @return {Promise}              resolves to the best available user friendly attribute name
     */
    aliasedFieldName (attribName) {
        // grab attribute info (waiting for it it finish loading)
        return this.getLayerData().then(lData => {
            return AttribFC.aliasedFieldNameDirect(attribName, lData.fields);
        });

    }

    /**
     * Get the best user-friendly name of a field. Uses alias if alias is defined, else uses the system attribute name.
     *
     * @param {String} attribName     the attribute name we want a nice name for
     * @param {Array} fields          list of field definition objects (esri format) for the layer.
     * @return {String}               the best available user friendly attribute name
     */
    static aliasedFieldNameDirect (attribName, fields) {
        let fName = attribName;

        // search for aliases
        if (fields) {
            const attribField = fields.find(field => {
                return field.name === attribName;
            });
            if (attribField && attribField.alias && attribField.alias.length > 0) {
                fName = attribField.alias;
            }
        }
        return fName;
    }

    /**
     * Convert an attribute set so that any keys using aliases are converted to proper fields
     *
     * @param  {Object} attribs      attribute key-value mapping, potentially with aliases as keys
     * @param  {Array} fields       fields definition array for layer
     * @return {Object}              attribute key-value mapping with fields as keys
     */
    static unAliasAttribs (attribs, fields) {
        const newA = {};
        fields.forEach(field => {
            // attempt to extract on name. if not found, attempt to extract on alias
            // dump value into the result
            newA[field.name] = attribs.hasOwnProperty(field.name) ? attribs[field.name] : attribs[field.alias];
        });
        return newA;
    }

    /**
     * Fetches a graphic from the given layer.
     * Will attempt local copy (unless overridden), will hit the server if not available.
     *
     * @function fetchGraphic
     * @param  {Integer} objectId      ID of object being searched for
     * @param {Object} opts            object containing option parametrs
     *                 - map           map wrapper object of current map. only required if requesting geometry
     *                 - geom          boolean. indicates if return value should have geometry included. default to false
     *                 - attribs       boolean. indicates if return value should have attributes included. default to false
     * @returns {Promise} resolves with a bundle of information. .graphic is the graphic; .layerFC for convenience
     */
    fetchGraphic (objectId, opts) {

        // see https://github.com/fgpv-vpgf/fgpv-vpgf/issues/2190 for reasons why
        // things are done the way they are in this function.

        // TODO this is currently a mess of IF statements, and a very dirty hack using a promise.
        //      could certainly use a refactor LATER.
        // this function should win a prize for good structure :trophy:

        const layerObj = this._parent._layer;
        const result = {
            graphic: null,
            layerFC: this
        };
        const resultFeat = {};

        const nonPoint = this.geomType !== 'esriGeometryPoint';
        let needWebAttr = false;
        let needWebGeom = false;
        let lod;
        let gCache;
        let aCache;
        let localGraphic;

        // basically this hack promise handles one odd case where we are getting attributes from
        // an asynch source that is very inconvenient (code would be mint if it was synch source).
        // so in all other cases, the promse just resolves. in the odd case, it waits, then updates
        // the result variable, then resolves.
        // so at both points in the code where the main return value promise resolves, we first
        // wait on this (which usually resolves right away, and the odd case the thing it's waiting
        // on is already resolved, but need to treat it like a promise because of rules!)
        let attribHackPromise = Promise.resolve();

        // subfunction to extract a graphic from a feature layerk
        const huntLocalGraphic = objId => {
            return layerObj.graphics.find(g =>
                g.attributes[layerObj.objectIdField] === objId);
        };

        if (opts.attribs) {
            // attempt to get attributes from fastest source.
            aCache = this._quickCache.attribs;
            if (aCache[objectId]) {
                // value is already cached. use it
                resultFeat.attributes = aCache[objectId];
            } else if (this._layerPackage.loadIsDone) {
                // all attributes have been loaded. use that store.
                // since our store is a promise, need to do some hack trickery here
                attribHackPromise = new Promise(resolve => {
                    this._layerPackage.getAttribs().then(ad => {
                        resultFeat.attributes = ad.features[ad.oidIndex[objectId]].attributes;
                        resolve();
                    });
                });

            } else if (this._parent.dataSource() !== shared.dataSources.ESRI && layerObj.graphics) {
                // it is a feature layer that is file based. we can extract info from it.
                localGraphic = huntLocalGraphic(objectId);
                resultFeat.attributes = localGraphic.attributes;

            } else {
                // we will need to ask the service
                needWebAttr = true;
            }
        }

        if (opts.geom) {
            // first locate the appropriate cache due to simplifications.
            gCache = this._quickCache.geoms;

            if (nonPoint) {
                // lines and polys have a cache for each LOD

                const mapLevel = opts.map.getLevel();
                lod = opts.map.lods.find(l => l.level === mapLevel);

                if (!gCache[lod.scale]) {
                    gCache[lod.scale] = {};
                }
                gCache = gCache[lod.scale];
            }

            // attempt to get geometry from fastest source.
            if (gCache[objectId]) {
                resultFeat.geometry = gCache[objectId];
            } else if (layerObj.graphics) {
                // it is a feature layer. we can attempt to extract info from it.
                // but remember the feature may not exist on the client currently
                if (!localGraphic) {
                    // wasn't fetched during attribute section. do it now
                    localGraphic = huntLocalGraphic(objectId);
                }

                if (localGraphic) {
                    // found one. cache it and use it
                    gCache[objectId] = localGraphic.geometry;
                    resultFeat.geometry = localGraphic.geometry;
                } else {
                    needWebGeom = true;
                }

            } else {
                needWebGeom = true;
            }
        }

        // hit the server if we dont have cached values
        if (needWebAttr || needWebGeom) {
            return new Promise(
                (resolve, reject) => {
                    const parent = this._parent;
                    const reqParam = {
                        url: `${parent.rootUrl}/${this._idx}/query`,
                        content: {
                            f: 'json',
                            objectIds: objectId,
                            outFields: '*',
                            returnGeometry: needWebGeom
                        },
                        callbackParamName: 'callback',
                        handleAs: 'json'
                    };

                    if (needWebGeom) {
                        reqParam.content.outSR = map.spatialReference;
                        if (nonPoint) {
                            reqParam.content.maxAllowableOffset = lod.resolution;
                        }
                    }

                    // TODO investigate adding `geometryPrecision` to the param.
                    //      if we have bloated decimal places, this will drop them.
                    //      need to be careful of the units of the map and the current scale.
                    //      e.g. a basemap in lat long will certainly need decimal places.

                    const defData = parent._esriRequest(reqParam);

                    defData.then(
                        queryResult => {
                            const feat = queryResult.features[0];

                            if (!feat) {
                                throw new Error(`Could not find feature (oid ${objectId})`);
                            }

                            if (needWebGeom) {
                                // server result omits spatial reference
                                feat.geometry.spatialReference = queryResult.spatialReference;
                                gCache[objectId] = feat.geometry;
                                resultFeat.geometry = feat.geometry;
                            }

                            if (needWebAttr) {
                                aCache[objectId] = feat.attributes;
                                resultFeat.attributes = feat.attributes;
                            }

                            result.graphic = resultFeat;
                            attribHackPromise.then(() => {
                                resolve(result);
                            });
                        }, error => {
                            console.warn(error);
                            reject(error);
                        }
                    );
                });
        } else {
            // no need for web requests. everything was available locally
            return attribHackPromise.then(() => {
                result.graphic = resultFeat;
                return result;
            });
        }
    }

    /**
     * Will attempt to zoom the map view so the a graphic is prominent.
     *
     * @function zoomToGraphic
     * @param  {Integer} objId          Object ID of grahpic being searched for
     * @param  {Object} map             wrapper object for the map we want to zoom
     * @param {Object} offsetFraction   an object with decimal properties `x` and `y` indicating percentage of offsetting on each axis
     * @return {Promise}                resolves after the map is done moving
     */
    zoomToGraphic (objId, map, offsetFraction) {

        return this.fetchGraphic(objId, { map, geom: true })
            .then(fetchedGraphic => {
                const gapi = this._parent._apiRef;

                // make new graphic (on the chance it came from server and is just raw json geometry)
                const graphic = gapi.proj.Graphic(fetchedGraphic.graphic);

                // reproject graphic to spatialReference of the map
                let extent = gapi.proj.graphicsUtils.graphicsExtent([graphic]);
                if (!gapi.proj.isSpatialRefEqual(graphic.geometry.spatialReference, map.spatialReference)) {
                    const intermExtent = gapi.proj.localProjectExtent(extent, map.spatialReference);
                    extent = gapi.Map.Extent(intermExtent.x0, intermExtent.y0,
                        intermExtent.x1, intermExtent.y1, intermExtent.sr);
                }

                // move map according to geometry
                let geomZoomPromise;
                if (this.geomType === 'esriGeometryPoint') {
                    // zoom to point at a decent scale for hilighting a point
                    const sweetLod = gapi.Map.findClosestLOD(map.lods, 50000);
                    geomZoomPromise = map.centerAndZoom(extent.getCenter(), Math.max(sweetLod.level, 0));
                } else {
                    // zoom to the extent of the geometery
                    geomZoomPromise = map.setExtent(extent, true);
                }

                // make next step wait for map to zoom, and pass it our projected target extent.
                return geomZoomPromise.then(() => extent);
            }).then(extent => {

                // determine if our optimal zoom is offscale
                const scale = this.isOffScale(map.getScale());

                // adjust the scale if the layer is offscale
                const scaleZoomPromise = scale.offScale ?
                    this.zoomToScale(map, map.lods, scale.zoomIn, false) : Promise.resolve();

                return scaleZoomPromise.then(() => extent);

            }).then(extent => {
                // map is at best position we can manage. do any offsetting for UI elements
                return map.moveToOffsetExtent(extent, offsetFraction);
            });
    }

    /**
     * Applies the current filter settings to the physical map layer.
     *
     * @function applyFilterToLayer
     * @param {Array} [exclusions] list of any filters to exclude from the result. omission includes all keys
     */
    applyFilterToLayer (exclusions = []) {
        // note DynamicFC will override this function to handle the dynamic layer case
        const p = this._parent;
        const sql = this.filter.getCombinedSql(exclusions);

        if (p.dataSource() === shared.dataSources.ESRI) {
            // feature layer on a server
            p.setDefinitionQuery(sql);
        } else {
            // file or wfs
            p._apiRef.query.sqlGraphicsVisibility(p._layer.graphics, sql);
        }
    }

    /**
     * Gets array of object ids that currently pass any filters
     *
     * @function getFilterOIDs
     *
     * @param {Array} [exclusions] list of any filters to exclude from the result. omission includes all filters
     * @param {Extent} [extent] if provided, the result list will only include features intersecting the extent
     * @returns {Promise} resolves with array of object ids that pass the filter. if no filters are active, resolves with undefined.
     */
    getFilterOIDs (exclusions = [], extent) {
        const sql = this.filter.getCombinedSql(exclusions);

        const p = this._parent;
        const api = p._apiRef;
        const opts = {
            geometry: extent,
            where: sql,
            mapScale: p._layer._map && p._layer._map.__LOD ? p._layer._map.__LOD.scale  : undefined,
            sourceWkid: p._layer.spatialReference ? p._layer.spatialReference.wkid : undefined
        };

        if (!(sql || extent)) {
            // no filters active. return undefined so caller can not worry about applying filters
            return Promise.resolve(undefined);
        }

        if (extent) {
            // essentially this determines if our extent was already cached,
            // bonks the cache if it is stale
            this.filter.setExtent(extent);
        }

        // this must be done after the setExtent() call, as that call can potentially invalidate caches
        const impactedFilters = this.filter.sqlActiveFilters(exclusions);
        let cache = this.filter.getCache(impactedFilters, extent);

        // TODO once things are working, attempt to make all the promise caching into worker functions.

        // if not cached, execute a query and store the result as the cache
        if (!cache) {
            if (p.dataSource() === shared.dataSources.ESRI) {
                // feature layer on a server. just use query task
                opts.mapScale = p._layer._map && p._layer._map.__LOD ? p._layer._map.__LOD.scale  : undefined;
                opts.sourceWkid = p._layer.spatialReference ? p._layer.spatialReference.wkid : undefined;
                opts.url = this.queryUrl;
                cache = api.query.queryIds(opts);
            } else {
                // file or wfs
                let eProm, sArray;

                if (extent) {
                    // execute a query against the map extent
                    // cannot do the where in the esri query for files
                    opts.where = '';
                    opts.featureLayer = p._layer;
                    eProm = api.query.queryIds(opts);
                    if (!sql) {
                        // nothing else to filter, set the cache
                        cache = eProm;
                    }
                } 
                if (sql) {
                    // use our custom filter to find graphics that satisfy our sql
                    const oid = this.oidField;
                    sArray = api.query.sqlAttributeFilter(p._layer.graphics, sql, true)
                                .map(a => a.attributes[oid]);
                    if (!extent) {
                        // nothing else to filter, set the cache
                        cache = Promise.resolve(sArray);
                    }
                }
                if (sql && extent) {
                    // combine the two results, cache it
                    cache = eProm.then(qArray => {
                        return shared.arrayIntersect(qArray, sArray);
                    });
                }
            }
            this.filter.setCache(cache, impactedFilters, extent);
        }
        return cache;
    }

}

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