layer/layerRec/dynamicRecord.js

'use strict';

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

/**
 * @class DynamicRecord
 */
class DynamicRecord extends attribRecord.AttribRecord {
    // TODO are we still using passthrough stuff?
    get _layerPassthroughBindings () {
        // TEST STATUS none
        return ['setOpacity', 'setVisibility', 'setVisibleLayers', 'setLayerDrawingOptions'];
    }
    get _layerPassthroughProperties () {
        // TEST STATUS none
        return ['visibleAtMapScale', 'visible', 'spatialReference', 'layerInfos', 'supportsDynamicLayers'];
    }

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

    /**
     * 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 dynamic 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) {
        // TEST STATUS basic
        super(layerClass, esriRequest, apiRef, config, esriLayer, epsgLookup);
        this.ArcGISDynamicMapServiceLayer = layerClass;

        // TODO what is the case where we have dynamic layer already prepared
        //      and passed in? Generally this only applies to file layers (which
        //      are feature layers).

        // TODO figure out controls on config
        // TODO worry about placeholders. WORRY. how does that even work here?

        this._proxies = {};

    }

    /**
     * Return a proxy interface for a child layer
     *
     * @param {Integer} featureIdx    index of child entry (leaf or group)
     * @return {Object}               proxy interface for given child
     */
    getChildProxy (featureIdx) {
        // TEST STATUS basic
        // TODO verify we have integer coming in and not a string
        // in this case, featureIdx can also be a group index
        if (this._proxies[featureIdx.toString()]) {
            return this._proxies[featureIdx.toString()];
        } else {
            // throw new Error(`attempt to get non-existing child proxy. Index ${featureIdx}`);

            // to handle the case of a structured legend needing a proxy for a child prior to the
            // layer loading, we treat an unknown proxy request as that case and return
            // a proxy loaded with a placeholder.
            // TODO how to pass in a name? add an optional second parameter? expose a "set name" on the proxy?
            const pfc = new placeholderFC.PlaceholderFC(this, '');
            const tProxy = new layerInterface.LayerInterface(pfc); // specificially no controls at this point.
            tProxy.convertToPlaceholder(pfc);
            this._proxies[featureIdx.toString()] = tProxy;
            return tProxy;

        }
    }

    // TODO I think we need to override getProxy to return a special Dynamic proxy.
    //      Need to figure out how visibility works (i.e. layer is invisible or just empty visibleChildren array)
    //      Might also need to manage the root children somehow (i.e. the layerEntries from the config)

    // TODO docs
    getFeatureCount (featureIdx) {
        // TEST STATUS basic
        // point url to sub-index we want
        // TODO might change how we manage index and url
        return super.getFeatureCount(this._layer.url + '/' + featureIdx);
    }

    /**
    * Triggers when the layer loads.
    *
    * @function onLoad
    */
    onLoad () {
        // TEST STATUS basic
        super.onLoad();
        const supportsDynamic = this._layer.supportsDynamicLayers;
        const controlBanlist = ['reload', 'snapshot', 'boundingBox'];
        if (!supportsDynamic) {
            controlBanlist.push('opacity');
        }

        // strip any banned controls from a controls array
        // array is modified
        const banControls = controls => {
            controlBanlist.forEach(bc => {
                const idx = controls.indexOf(bc);
                if (idx > -1) {
                    controls.splice(idx, 1);
                }
            });

            // a bit redundant. useful if we are passing in an anonymous array.
            return controls;
        };

        // don't worry about structured legend. the legend part is separate from
        // the layers part. we just load what we are told to. the legend module
        // will handle the structured part.

        // NOTE for now, the only relevant properties to be propagated
        //      from parent to child are .state and .controls .
        //      .outfields does not make sense as chilren can have different fields.
        //      We assume the objects at the layer level (index -1) are fully defaulted.
        //      All other missing items assigned from parent item.

        // subconfig lookup. initialize with the layer root (-1), then add
        // in anything provided in the initial config.
        const subConfigs = {
            '-1': {
                config: {
                    state: this.config.state,
                    controls: banControls(this.config.controls.concat())
                },
                defaulted: true
            }
        };

        this.config.layerEntries.forEach(le => {
            subConfigs[le.index.toString()] = {
                config: le,
                defaulted: false
            };
        });

        // subfunction to either return a stored sub-config, or
        // derive a new subconfig from the parent config.
        // both params integers in string format.
        const fetchSubConfig = (id, parentId) => {
            if (subConfigs[id]) {
                const subC = subConfigs[id];
                if (!subC.defaulted) {
                    // get any missing properties from parent
                    const parent = subConfigs[parentId].config;

                    // TODO verify if we need to check for controls array of .length === 0.
                    //      I am assuming an empty array a valid setting (i.e. no controls should be shown)
                    if (!subC.config.controls) {
                        // we can assume parent.controls has already been ban-scraped
                        subC.config.controls = parent.controls.concat();
                    } else {
                        // ensure we dont have any bad controls lurking
                        banControls(subC.config.controls);
                    }

                    if (!subC.config.state) {
                        // copy all
                        subC.config.state = Object.assign({}, parent.state);
                    } else {
                        // selective inheritance
                        Object.keys(parent.state).forEach(stateKey => {
                            // be aware of falsey logic here.
                            if (!subC.config.state.hasOwnProperty(stateKey)) {
                                subC.config.state[stateKey] = parent.state[stateKey];
                            }
                        });
                    }

                    if (!subC.config.hasOwnProperty('outfields')) {
                        subC.config.outfields = '*';
                    }

                    subC.defaulted = true;
                }
                return subC.config;
            } else {
                // no config at all. direct copy properties from parent
                // we can assume parent.controls has already been ban-scraped
                const newConfig = {
                    state: Object.assign({}, subConfigs[parentId].config.state),
                    controls: subConfigs[parentId].config.controls.concat(),
                    outfields: '*'
                };
                subConfigs[id] = {
                    config: newConfig,
                    defaulted: true
                };
                return newConfig;
            }
        };

        // this subfunction will recursively crawl a dynamic layerInfo structure.
        // it will generate proxy objects for all groups and leafs under the
        // input layerInfo.
        // it also collects and returns an array of leaf nodes so each group
        // can store it and have fast access to all leaves under it.
        const processLayerInfo = (layerInfo, treeArray, parentId) => {
            const sId = layerInfo.id.toString();
            const subConfig = fetchSubConfig(sId, parentId.toString());
            if (layerInfo.subLayerIds && layerInfo.subLayerIds.length > 0) {
                // group
                // TODO probably need some placeholder magic going on here too
                // TODO do we need to apply any config state?
                // TODO figure out control lists, whats available, whats disabled.
                //      supply on second and third parameters
                let group;
                if (this._proxies[sId]) {
                    // we have a pre-made proxy (structured legend)
                    // TODO might need to pass controls array into group proxy
                    group = this._proxies[sId];
                } else {
                    // set up new proxy
                    group = new layerInterface.LayerInterface(this, subConfig.controls);
                    this._proxies[sId] = group;

                }
                group.convertToDynamicGroup(this, sId, subConfig.name || layerInfo.name || '');

                const treeGroup = { id: layerInfo.id, childs: [] };
                treeArray.push(treeGroup);

                // process the kids in the group.
                // store the child leaves in the internal variable
                layerInfo.subLayerIds.forEach(slid => {
                    group._childLeafs = group._childLeafs.concat(
                        processLayerInfo(this._layer.layerInfos[slid], treeGroup.childs, sId));
                });

                return group._childLeafs;
            } else {
                // leaf
                // TODO figure out control lists, whats available, whats disabled.
                //      supply on second and third parameters.
                //      might need to steal from parent, since auto-gen may not have explicit
                //      config settings.
                // TODO since we are doing placeholder, might want to not provide controls array yet.
                let leaf;
                const pfc = new placeholderFC.PlaceholderFC(this, layerInfo.name);
                if (this._proxies[sId]) {
                    // we have a pre-made proxy (structured legend)
                    // TODO might need to pass controls array into leaf proxy
                    leaf = this._proxies[sId];
                    leaf.updateSource(pfc);
                } else {
                    // set up new proxy
                    leaf = new layerInterface.LayerInterface(null, subConfig.controls);
                    leaf.convertToPlaceholder(pfc);
                    this._proxies[sId] = leaf;
                }

                treeArray.push({ id: layerInfo.id });
                return [leaf];
            }
        };

        this._childTree = []; // public structure describing the tree
        if (this.config.layerEntries) {
            this.config.layerEntries.forEach(le => {
                if (!le.stateOnly) {
                    processLayerInfo(this._layer.layerInfos[le.index], this._childTree, -1);
                }
            });
        }

        // trigger attribute load and set up children bundles.
        // TODO do we need an options object, with .skip set for sub-layers we are not dealing with?
        //      we currently (sort-of) have the list of things included -- the keys of the
        //      subConfigs object. we would need to iterate layerInfos again and find keys
        //      not in subConfigs.
        //      Alternate: add new option that is opposite of .skip.  Will be more of a
        //                 .only, and we won't have to derive a "skip" set from our inclusive
        //                 list
        //      Furthermore: skipping / being effecient might not really matter here anymore.
        //                   back in the day, loadLayerAttribs would actually load everything.
        //                   now it just sets up promises that dont trigger until someone asks for
        //                   the information.
        const attributeBundle = this._apiRef.attribs.loadLayerAttribs(this._layer);
        const initVis = [];

        // converts server type string to client type string
        const serverLayerTypeToClientLayerType = serverType => {
            switch (serverType) {
                case 'Feature Layer':
                    return shared.clientLayerType.ESRI_FEATURE;
                case 'Raster Layer':
                    return shared.clientLayerType.ESRI_RASTER;
                default:
                    throw new Error('Unexpected layer type in serverLayerTypeToClientLayerType', serverType);
            }
        };

        // idx is a string
        attributeBundle.indexes.forEach(idx => {
            // if we don't have a defaulted sub-config, it means the attribute leaf is not present
            // in our visible tree structure.
            const subC = subConfigs[idx];
            if (subC && subC.defaulted) {
                // TODO need to worry about Raster Layers here.  DynamicFC is based off of
                //      attribute things.
                const dFC = new dynamicFC.DynamicFC(this, idx, attributeBundle[idx], subC.config);
                this._featClasses[idx] = dFC;
                if (subC.config.state.visibility) {
                    initVis.push(parseInt(idx)); // store for initial visibility
                }

                // if we have a proxy watching this leaf, replace its placeholder with the real data
                const leafProxy = this._proxies[idx];
                if (leafProxy) {
                    // TODO update controls array?

                    // trickery involving symbology.
                    // the UI is binding to the object that was set up in the leaf placeholder.
                    // so we cannot just make a new one.
                    // we need to inject the placeholder symbology object into our new DynamicFC.
                    // then we can aysnch update it with real symbols, and the UI is still
                    // pointing at the same array in memory.
                    dFC._symbolBundle = leafProxy.symbology;
                    leafProxy.convertToDynamicLeaf(dFC);
                }

                // load real symbols into our source
                dFC.loadSymbology();

                // update asynchronous values
                dFC.getLayerData().then(ld => {
                    dFC.layerType = serverLayerTypeToClientLayerType(ld.layerType);
                    if (dFC.layerType === shared.clientLayerType.ESRI_FEATURE) {
                        dFC.geomType = ld.geometryType;
                    }
                });
            }
        });

        // need to do a post ban-sweep on control arrays. dynamic groups are not allowed
        // to have opacity. if we had removed above, children of groups would have also
        // lost opacity.
        // a lovely pyramid of doom.
        Object.keys(this._proxies).forEach(sId => {
            const proxy = this._proxies[sId];
            if (!proxy.isPlaceholder && proxy.layerType === shared.clientLayerType.ESRI_GROUP) {
                const poIdx = proxy.availableControls.indexOf('opacity');
                if (poIdx > -1) {
                    proxy.availableControls.splice(poIdx, 1);

                    // TODO test if we need to adjust subconfigs, or if it's all the same pointer
                    if (subConfigs[sId].config.controls.indexOf('opacity') > -1) {
                        console.log('HEEEY HEYYY WE HAVE A CONFIG OPACITY GROUP, ADD CODE TO REMOVE IT');
                    }
                }
            }
        });

        if (initVis.length === 0) {
            initVis.push(-1); // esri code for set all to invisible
        }
        this._layer.setVisibleLayers(initVis);
    }

    // override to add child index parameter
    zoomToScale (childIdx, map, lods, zoomIn, zoomGraphic = false) {
        // TEST STATUS none
        // get scale set from child, then execute zoom
        return this._featClasses[childIdx].getScaleSet().then(scaleSet => {
            return this._zoomToScaleSet(map, lods, zoomIn, scaleSet, zoomGraphic);
        });
    }

    isOffScale (childIdx, mapScale) {
        // TEST STATUS none
        return this._featClasses[childIdx].isOffScale(mapScale);
    }

    isQueryable (childIdx) {
        // TEST STATUS none
        return this._featClasses[childIdx].queryable;
    }

    // TODO if we need this back, may need to implement as getChildGeomType.
    //      appears this ovverrides the LayerRecord.getGeomType function, which returns
    //      undefined, and that is what we want on the DynamicRecord level (as dynamic layer)
    //      has no geometry.
    //      Currently, all child requests for geometry go through the proxy,
    //      so could be this child-targeting version is irrelevant.
    /*
    getGeomType (childIdx) {
        // TEST STATUS none
        return this._featClasses[childIdx].geomType;
    }
    */

    getChildTree () {
        if (this._childTree) {
            return this._childTree;
        } else {
            throw new Error('Called getChildTree before layer is loaded');
        }
    }

    /**
     * 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 {String}  childIndex    index of the child layer whos attributes we are looking at
     * @return {Promise}              resolves to the best available user friendly attribute name
     */
    aliasedFieldName (attribName, childIndex) {
        // TEST STATUS none
        return this._featClasses[childIndex].aliasedFieldName(attribName);
    }

    /**
     * Retrieves attributes from a layer for a specified feature index
     * @param {String}  childIndex  index of the child layer to get attributes for
     * @return {Promise}            promise resolving with formatted attributes to be consumed by the datagrid and esri feature identify
     */
    getFormattedAttributes (childIndex) {
        // TEST STATUS none
        return this._featClasses[childIndex].getFormattedAttributes();
    }

    /**
     * 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
     * @param {String}  childIndex    index of the child layer whos attributes we are looking at
     * @return {Promise}              resolves to true or false based on the attribName type being esriFieldTypeDate
     */
    checkDateType (attribName, childIndex) {
        // TEST STATUS none
        return this._featClasses[childIndex].checkDateType(attribName);
    }

    /**
    * Returns attribute data for a child layer.
    *
    * @function getAttribs
    * @param {String} childIndex  the index of the child layer
    * @returns {Promise}          resolves with a layer attribute data object
    */
    getAttribs (childIndex) {
        // TEST STATUS none
        return this._featClasses[childIndex].getAttribs();
    }

    /**
    * Returns layer-specific data for a child layer
    *
    * @function getLayerData
    * @param {String} childIndex  the index of the child layer
    * @returns {Promise}          resolves with a layer data object
    */
    getLayerData (childIndex) {
        // TEST STATUS none
        return this._featClasses[childIndex].getLayerData();
    }

    getFeatureName (childIndex, objId, attribs) {
        // TEST STATUS none
        return this._featClasses[childIndex].getFeatureName(objId, attribs);
    }

    getSymbology (childIndex) {
        // TEST STATUS basic
        return this._featClasses[childIndex].getSymbology();
    }

    /**
    * Run a query on a dynamic layer, return the result as a promise.
    * @function identify
    * @param {Object} opts additional argumets like map object, clickEvent, etc.
    * @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) {
        // TEST STATUS none
        // TODO caller must pass in layer ids to interrogate.  geoApi wont know what is toggled in the legend.
        //      param is opts.layerIds, array of integer for every leaf to interrogate.
        // TODO add full documentation for options parameter

        // bundles results from all leaf layers
        const identifyResults = [];

        // create an results object for every leaf layer we are inspecting
        opts.layerIds.forEach(leafIndex => {

            // TODO fix these params
            // TODO legendEntry.name, legendEntry.symbology appear to be fast links to populate the left side of the results
            //      view.  perhaps it should not be in this object anymore?
            // TODO see how the client is consuming the internal pointer to layerRecord.  this may also now be
            //      directly available via the legend object.
            const identifyResult =
                new shared.IdentifyResult('legendEntry.name', 'legendEntry.symbology', 'EsriFeature', this,
                    leafIndex, 'legendEntry.master.name'); // provide name of the master group as caption

            identifyResults[leafIndex] = identifyResult;
        });

        opts.tolerance = this.clickTolerance;

        const identifyPromise = this._apiRef.layer.serverLayerIdentify(this._layer, opts)
            .then(clickResults => {
                const hitIndexes = []; // sublayers that we got results for

                // transform attributes of click results into {name,data} objects
                // one object per identified 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
                clickResults.forEach(ele => {
                    // NOTE: the identify service returns aliased field names, so no need to look them up here.
                    //       however, this means we need to un-alias the data when doing field lookups.
                    // NOTE: ele.layerId is what we would call featureIdx
                    hitIndexes.push(ele.layerId);

                    // get metadata about this sublayer
                    this.getLayerData(ele.layerId).then(lData => {
                        const identifyResult = identifyResults[ele.layerId];

                        if (lData.supportsFeatures) {
                            const unAliasAtt = attribFC.AttribFC.unAliasAttribs(ele.feature.attributes, lData.fields);

                            // TODO traditionally, we did not pass fields into attributesToDetails as data was
                            //      already aliased from the server. now, since we are extracting field type as
                            //      well, this means things like date formatting might not be applied to
                            //      identify results. examine the impact of providing the fields parameter
                            //      to data that is already aliased.
                            identifyResult.data.push({
                                name: ele.value,
                                data: this.attributesToDetails(ele.feature.attributes),
                                oid: unAliasAtt[lData.oidField],
                                symbology: [{
                                    svgcode: this._apiRef.symbology.getGraphicIcon(unAliasAtt, lData.renderer)
                                }]
                            });
                        }
                        identifyResult.isLoading = false;
                    });
                });

                // set the rest of the entries to loading false
                identifyResults.forEach(identifyResult => {
                    if (hitIndexes.indexOf(identifyResult.requester.featureIdx) === -1) {
                        identifyResult.isLoading = false;
                    }
                });

            });

        return {
            identifyResults: identifyResults.filter(identifyResult => identifyResult), // collapse sparse array
            identifyPromise
        };
    }

    // TODO docs
    getChildName (index) {
        // TEST STATUS none
        // TODO revisit logic. is this the best way to do this? what are the needs of the consuming code?
        // TODO restructure so WMS can use this too?
        // will not use FC classes, as we also need group names
        return this._layer.layerInfos[index].name;
    }

}

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