Source: visual-layer/src/base-layer/base-layer.js

import {
    getUniqueId,
    mergeRecursive,
    Store,
    FieldType,
    selectElement,
    ReservedFields,
    registerListeners,
    transactor,
    DataModel,
    clone
} from 'muze-utils';
import { SimpleLayer } from '../simple-layer';
import * as PROPS from '../enums/props';
import {
    transformData,
    calculateDomainFromData,
    getNormalizedData,
    applyInteractionStyle
} from '../helpers';
import { listenerMap } from './listener-map';
import { defaultOptions } from './default-options';

/**
 * An abstract class which gives definition of common layer functionality like
 * - transforming data for various modes. Supported modes: identity, group and stack.
 * - calculating data domain
 * - linking dependent layers
 * - merging policy of configuration
 * - interaction sideffect helpers
 * - retrieving dom elements from data using id
 * - retrieving the physical dimensions of marks
 * - disposing layer
 *
 * Every layer has to extend base layer and give concrete definition.
 * This layer does not have any default visual. A new layer has to define the logic of `render` for rendering the
 * visuals
 *
 * @public
 * @class
 * @module BaseLayer
 */
export default class BaseLayer extends SimpleLayer {

    /**
     * Creates a layer using a configuration and data.
     *
     * @public
     * @constructor
     * @param {DataModel} data Instance of DataModel to be used. This DataModel instance serves as the data for a layer.
     * @param {Object} axes Axes instances to be used for rendering the layer. Axes are used for mapping data from
     *      value to px.
     * @param {SimpleAxis} axes.x X axis of the layer. Based on the type of variable it gets instance of BandAxis,
     *      TimeAxis, ContinuousAxis
     * @param {SimpleAxis} axes.y X axis of the layer. Based on the type of variable it gets instance of BandAxis,
     *      TimeAxis, ContinuousAxis
     * @param {ColorAxis} axes.color Axis for coloring a layer using color interpolators
     * @param {ShapeAxis} axes.shape Axis for providing a shape
     * @param {SizeAxis} axes.size Axis for determining size of a mark using size interpolator
     * @param {LayerConfig} config Configuration of the layer
     * @param {Object} dependencies Dependencies of the layer
     * @param {SmartLabel} dependencies.smartLabel Smartlabel singleton instance
     */
    constructor (data, axes, config, dependencies) {
        super();
        this.store(new Store({
            DATA: null,
            [PROPS.DATA_UPDATED]: null
        }));
        transactor(this, defaultOptions, this.store().model);
        this.data(data);
        this.axes(axes);
        this.config(config);
        this.alias(this.constructor.formalName() + getUniqueId());
        this.dependencies(dependencies);
        this._points = [];
        this._cachedData = [];
        this._id = getUniqueId();
        this._measurement = {};
        registerListeners(this, listenerMap);
    }

    /**
     * Creates a layer instance
     * @return {BaseLayer} Instance of a layer
     */
    static create (...params) {
        return new this(...params);
    }

    /**
     * Default configuration of the layer. This configuration gets merged to the user passed configuration using a
     * plolicy. Base layer only returns part of configuraion, any layer overridding base layer should return its own
     * configuration.
     *
     * @public
     * @static
     *
     * @return {Object} Default configuration
     */
    static defaultConfig () {
        return {
            transform: {
                type: 'identity'
            }
        };
    }

    /**
     * Policy defines how user config gets merged to default config. The default policy here does a deep copy
     * operation.
     * Any policy which does more than deep copying should define the policy as a static member.
     *
     * @static
     * @public
     *
     * @param {LayerConfig} conf Configuration with which the user config will be merged
     * @param {LayerConfig} userConf Configuration given by the user
     *
     * @return {LayerConfig} Merged layer configuration
     */
    static defaultPolicy (conf, userConf) {
        return mergeRecursive(conf, userConf);
    }

    /**
     * Determines a name for a layer. This name of the layer is used in the input data to refer to this layer.
     * ```
     *  .layer([
     *      mark: 'bar',
     *      encoding: { ... }
     *  ])
     * ```
     *
     * @static
     * @public
     *
     * @return {string} name of layer
     */
    static formalName () {
        return 'base';
    }

    store (...store) {
        if (store.length) {
            this._store = store[0];
            return this;
        }
        return this._store;
    }

    encodingFieldsInf (...fieldsInf) {
        if (fieldsInf.length) {
            this._encodingFieldsInf = fieldsInf[0];
            return this;
        }
        return this._encodingFieldsInf;
    }

    encodingTransform (...encodingTransform) {
        if (encodingTransform.length) {
            this._encodingTransform = encodingTransform[0];
            return this;
        }
        return this._encodingTransform;
    }

    /**
     * Provides a alias for a layer. Like it's possible to have same layer (like bar) multiple times, but among multiple
     * layers of same type if one layer has to be referred, alias is used. If no alias is given then `formalName` is set
     * as the alias name.
     *
     *
     * If used as setter
     * @param  {string} alias Name of the alias
     * @return {BaseLayer} Instance of current base layer
     *
     * If used as getter
     * @return {string} Alias of the current layer
     *
     * @public
     */
    alias (...params) {
        if (params.length) {
            this._alias = params[0];
            return this;
        }
        return this._alias || this.constructor.formalName();
    }

    dependencies (...params) {
        if (params.length) {
            this._dependencies = params[0];
            return this;
        }
        return this._dependencies;
    }

    enableCaching () {
        this._cacheEnabled = true;
        return this;
    }

    clearCaching () {
        this._cacheEnabled = false;
        return this.data(this._cachedData[0]);
    }

    /**
     * Serialize the schema. Merge config is used for serialization.
     *
     * @public
     *
     * @return {LayerConfig} Serialized schema
     */
    serialize () {
        return this.config();
    }

    /**
     * Returns the unique identifier of this layer. Id is auto generated during the creation proceess of a schema.
     *
     * @public
     *
     * @return {string} id of the layer
     */
    id () {
        return this._id;
    }

    /**
     * Returns the transformed data based on given transform type.
     * It first gets the transform method from transform factory based on type of transform. It then calls the
     * transform method with the data and passes the configuration parameters of transform such as
     * groupBy, value field, etc.
     *
     * @param {DataModel} dataModel Instance of DataModel
     * @param {Object} config Configuration for transforming data
     * @return {Array.<Array>} Transformed data.
     */
    getTransformedData (dataModel, config, transformType, encodingFieldsInf) {
        return transformData(dataModel, config, transformType, encodingFieldsInf);
    }

    /**
     * Calculates the domain from the data.
     * It checks the type of field and calculates the domain based on that. For example, if it
     * is a quantitative or temporal field, then it calculates the min and max from the data or
     * if it is a categorical field then it gets all the values from the data of that field.
     * @param {Array} data DataArray
     * @param {Object} fieldsConfig Configuration of fields
     * @return {Array} Domain values array.
     */
    calculateDomainFromData (data) {
        let domains = {};
        const isEmpty = this.data().isEmpty();

        if (!isEmpty) {
            domains = calculateDomainFromData(data, this.encodingFieldsInf(), this.transformType());
        }
        return domains;
    }

    shouldDrawAnchors () {
        return false;
    }

    /**
     * Returns the domain for the axis.
     *
     * @param {string} encodingType type of encoding x, y, etc.
     * @return {Object} Axis domains
     */
    getDataDomain (encodingType) {
        const domains = this.store().get(PROPS.DOMAIN);
        return encodingType !== undefined ? domains[encodingType] || [] : domains;
    }

    /**
     * Normalizes the transformed data and returns it.
     *
     * @param {string} encodingType type of encoding x, y, etc.
     * @return {Object} Axis domains
     */
    getNormalizedData (transformedData, fieldsConfig) {
        return getNormalizedData(transformedData, fieldsConfig, this.encodingFieldsInf(), this.transformType());
    }

    /**
     * Gets the nearest point closest to the given x and y coordinate. If no nearest point is found, then it returns
     * null.
     *
     * @public
     *
     * @param {number} x X Coordinate.
     * @param {number} y Y Coordinate.
     *
     * @return {Object} Information of the nearest point.
     * ```
     *      {
     *          // id property contains the field names and their corresponding values in a 2d array. This is the data
     *          // associated with the nearest point.
     *          id: // Example data: [['Origin'], ['USA']],
     *          dimensions: // Physical dimensions of the point.
     *          layerId: // Id of the layer instance.
     *      }
     * ```
     */
    getNearestPoint () {
        return null;
    }

    applyInteractionStyle (interactionType, selectionSet, apply, styles) {
        const interactionConfig = this.config().interaction || {};

        let interactionStyles = interactionConfig[interactionType];
        interactionStyles = styles || interactionStyles;
        if (interactionStyles) {
            applyInteractionStyle(this, selectionSet, interactionStyles, {
                apply,
                interactionType
            });
        }
    }

    /**
     *
     *
     *
     * @memberof BaseLayer
     */
    transformType (...transformType) {
        if (transformType.length) {
            this._transformType = transformType[0];
            return this;
        }
        return this._transformType;
    }

    /**
     * Renders the layer
     * @return {BaseLayer} Instance of the layer.
     */
    render () {
        return this;
    }

    /**
     *
     *
     *
     * @memberof BaseLayer
     */
    elemType () {
        return 'g';
    }

    /**
     * Disposes the entire layer.
     *
     * @return {BaseLayer} Instance of layer.
     */
    remove () {
        this.store().unsubscribeAll();
        selectElement(this.mount()).remove();
        return this;
    }

    /**
     * Stores point in an object with key as the categorical value or temporal value
     *
     * @param {string} key categorical value or temporal value
     * @param {Object} data Information of the data point
     * @return {BarLayer} Instance of bar layer
     */
    cachePoint (key, data) {
        if (key === null) {
            return this;
        }
        const pointMap = this._pointMap;
        !pointMap[key] && (pointMap[key] = []);
        pointMap[key].push(data);
        return this;
    }

    /**
     *
     *
     * @param {*} dataProps
     *
     * @memberof BaseLayer
     */
    dataProps (...dataProps) {
        if (dataProps.length) {
            this._dataProps = dataProps[0];
            return this;
        }
        return this._dataProps;
    }

    /**
     *
     *
     * @param {*} data
     * @param {*} id
     *
     * @memberof BaseLayer
     */
    getIdentifiersFromData (data) {
        const schema = this.data().getData().schema;
        const fieldsConfig = this.data().getFieldsConfig();
        const identifiers = [[], []];
        const {
                xFieldType,
                yFieldType,
                xField,
                yField
            } = this.encodingFieldsInf();

        const [xMeasure, yMeasure] = [xFieldType, yFieldType].map(type => type === FieldType.MEASURE);
        schema.forEach((d, i) => {
            const name = d.name;
            if (fieldsConfig[name].def.type === FieldType.DIMENSION) {
                identifiers[0].push(name);
                identifiers[1].push(data[i]);
            }
        });

        if (xMeasure && yMeasure) {
            const xMeasureIndex = fieldsConfig[xField].index;
            const yMeasureIndex = fieldsConfig[yField].index;
            identifiers[0].push(...[xField, yField]);
            identifiers[1].push(...[data[xMeasureIndex], data[yMeasureIndex]]);
        }
        return identifiers;
    }

    getPlotSpan () {
        return {
            x: 0,
            y: 0
        };
    }

    getPlotPadding () {
        return {
            x: 0,
            y: 0
        };
    }

    /**
     * Returns the information of the marks corresponding to the supplied identifiers. Identifiers are a set of field
     * names and their corresponding values in an array. It can also be an instance of datamodel.
     *
     * For example,
     * ```
     *  const identifiers = [
     *      ['Origin', 'Cylinders'],
     *      ['USA', '8']
     *  ];
     *  const points = barLayer.getPointsFromIdentifiers(identifiers);
     * ```
     * @public
     * @param {Array|DataModel} identifiers Identifiers of the marks.
     * @param {Object} config Optional configuration which describes how to get the information.
     * @param {boolean} config.getAllAttrs If true, then returns all the information of the points, else returns only
     * the positions of the points.
     * @param {boolean} config.getBBox If true, then returns the bounding box of all the marks.
     *
     * @return {Array} Array of points contains
     */
    getPointsFromIdentifiers (identifiers, config = {}) {
        const getAllAttrs = config.getAllAttrs;
        const getBBox = config.getBBox;
        if (!this.data()) {
            return [];
        }
        let fieldNames;
        let values;
        if (identifiers instanceof DataModel) {
            const dataObj = identifiers.getData();
            fieldNames = dataObj.schema.map(d => d.name);
            values = dataObj.data;
        } else {
            fieldNames = identifiers[0];
            values = identifiers.slice(1, identifiers.length);
        }

        const points = this._points;
        const fieldsConfig = this.data().getFieldsConfig();

        const filteredPoints = [].concat(...points).filter((point) => {
            const { _data, _id } = point;

            return fieldNames.every((field, idx) => {
                if (field in fieldsConfig && fieldsConfig[field].def.type === FieldType.DIMENSION) {
                    return values.findIndex(d => d[idx] === _data[fieldsConfig[field].index]) !== -1;
                } else if (field === ReservedFields.ROW_ID) {
                    return values.findIndex(d => d[idx] === _id) !== -1;
                } return true;
            });
        });
        return getAllAttrs ? filteredPoints : filteredPoints.map((d) => {
            const obj = clone(d);
            if (getBBox) {
                const update = obj.update || obj;
                if (obj.size !== undefined) {
                    const sizeVal = Math.sqrt(obj.size / Math.PI) * 2;
                    update.width = sizeVal;
                    update.height = sizeVal;
                    update.x -= sizeVal / 2;
                    update.y -= sizeVal / 2;
                } else {
                    if (update.width === undefined) {
                        update.width = 2;
                    }
                    if (update.height === undefined) {
                        update.height = 2;
                    }
                }
            }

            return obj.update || obj;
        }).sort((a, b) => a.y - b.y);
    }

    getTransformedDataFromIdentifiers (identifiers) {
        const { data: identifierData, schema: identifierSchema } = identifiers.getData();
        const normalizedData = this.store().get(PROPS.NORMALIZED_DATA);
        const fieldsConfig = this.data().getFieldsConfig();
        const {
            yField,
            xField,
            yFieldType,
            xFieldType
        } = this.encodingFieldsInf();
        let measureIndex;
        let enc;
        if (xFieldType === FieldType.MEASURE) {
            measureIndex = fieldsConfig[xField].index;
            enc = 'x';
        } else if (yFieldType === FieldType.MEASURE) {
            measureIndex = fieldsConfig[yField].index;
            enc = 'y';
        }

        const transformedData = [];
        normalizedData.forEach((dataArr) => {
            dataArr.forEach((dataObj) => {
                const tupleArr = dataObj._data;
                const exist = identifierSchema.every((obj, i) =>
                    identifierData.findIndex(d => tupleArr[fieldsConfig[obj.name].index] === d[i]) !== -1);
                if (exist) {
                    const transformedVal = dataObj[enc];
                    const row = dataObj._data;
                    const tuple = {};
                    for (const key in fieldsConfig) {
                        const index = fieldsConfig[key].index;
                        tuple[key] = row[index];
                        if (index === measureIndex) {
                            tuple[key] = transformedVal;
                        }
                    }
                    transformedData.push(tuple);
                }
            });
        });

        return [transformedData, this.data().getData().schema];
    }

    /**
     * Returns the dom elements associated with the supplied set of row ids.
     * Each element in the layer is mapped with a row of the datamodel. When given an array of row ids, it returns all
     * the elements which is mapped with those row ids.
     *
     * @public
     * @param {Array} set Array of row ids
     *
     * @return {Selection} D3 Selection of dom elements.
     */
    getPlotElementsFromSet (set) {
        return selectElement(this.mount()).selectAll(this.elemType()).filter(data =>
            (data ? set.indexOf(data._id) !== -1 : false));
    }
}