Source: visual-unit/src/visual-unit.js

import { layerFactory } from '@chartshq/visual-layer';
import {
    setAttrs,
    CommonProps,
    getUniqueId,
    getQualifiedClassName,
    selectElement,
    transactor,
    Store,
    makeElement,
    registerListeners,
    generateGetterSetters,
    getDataModelFromIdentifiers,
    isSimpleObject,
    transposeArray,
    FieldType
} from 'muze-utils';
import { physicalActions, sideEffects, behaviouralActions, behaviourEffectMap } from '@chartshq/muze-firebolt';
import { actionBehaviourMap } from './firebolt/action-behaviour-map';
import {
    renderLayers,
    getNearestDimensionalValue,
    removeLayersBy,
    getLayersBy,
    getLayerFromDef,
    attachAxisToLayers,
    getLayerAxisIndex,
    createSideEffectGroup,
    getAdjustedDomain,
    resolveEncodingTransform
} from './helper';
import { renderGridLineLayers } from './helper/grid-lines';
import localOptions from './local-options';
import { listenerMap } from './listener-map';
import {
    primaryYAxisUpdated,
    primaryXAxisUpdated,
    secondaryXAxisUpdated,
    secondaryYAxisUpdated,
    DATADOMAIN,
    TIMEDIFFS
} from './enums/reactive-props';
import { PROPS } from './props';
import UnitFireBolt from './firebolt';
import './styles.scss';

const FORMAL_NAME = 'unit';

/**
 * Visual Unit is hierarchical component created by {@link VisualGroup}. This component accepts layer definitions
 * and creates concrete layer instances from them, binds data and attaches axis to them. It also retreives the domain
 * from the layers and unions them and sets them on corresponding axis instances. This also creates the parent svg
 * groups for all the layers and delegates the rendering to all the layers.
 *
 * @public
 * @module VisualUnit
 * @filename VisualUnit
 * @class
 */
export default class VisualUnit {

    /**
     * Creates instance of visualization unit.
     *
     * @param {Object} registry  Component registry
     * @param {Object} dependencies  Dependencies required by visual unit.
     */
    constructor (registry, dependencies) {
        this._id = getUniqueId();
        this._dependencies = dependencies;
        this._layerDeps = {
            throwback: new Store({
                onlayerdraw: false
            }),
            smartLabel: dependencies.smartLabel
        };
        this._renderedResolve = null;
        this._renderedPromise = new Promise((resolve) => {
            this._renderedResolve = resolve;
        });
        this._layerDeps.throwback.registerChangeListener([CommonProps.ON_LAYER_DRAW], () => {
            this._renderedResolve();
            this._lifeCycleManager.notify({ client: this.layers(), action: 'drawn', formalName: 'layer' });
        });

        this._lifeCycleManager = dependencies.lifeCycleManager;
        this._layersMap = {};
        this._gridlines = [];
        this._gridbands = [];
        this._layerAxisIndex = {};
        this._transformedDataModels = {};

        layerFactory.setLayerRegistry(registry.layerRegistry);
        generateGetterSetters(this, PROPS);
        this.cachedData([]);
        this.store(new Store({
            [primaryXAxisUpdated]: null,
            [primaryYAxisUpdated]: null,
            [secondaryXAxisUpdated]: null,
            [secondaryYAxisUpdated]: null
        }));
        transactor(this, localOptions, this.store().model);
        this.firebolt(new UnitFireBolt(this, {
            physical: physicalActions,
            behavioural: behaviouralActions,
            physicalBehaviouralMap: actionBehaviourMap
        }, sideEffects, behaviourEffectMap));
        registerListeners(this, listenerMap);
    }

    static formalName () {
        return FORMAL_NAME;
    }

    /**
     * Static helper for creates a unit instance
     *
     * @param {Object} [id] optional unique identifier for a unit; , id is calculated internally
     * @param {DataModel} data instance of datamodel
     * @param {Array.<Layer>} layers layer configuration
     * @param {Object} config configurtion for the visual unit
     * @return {VisualUnit} Instance of a unit
     */
    static create (...params) {
        return new this(...params);
    }

    /**
     * Returns the instance of firebolt associated with this visual unit. Firebolt dispatches the behavioural actions
     * when any physical action happens on the elements of visual unit.
     *
     * @public
     *
     * @return {Firebolt} Instance of firebolt.
     */
    firebolt (...firebolt) {
        if (firebolt.length) {
            this._firebolt = firebolt[0];
            return this;
        }
        return this._firebolt;
    }

    /**
     * Gets the domain for all axes of this visual unit.
     *
     * @return {Object} Domains of each data field.
     */
    getDataDomain () {
        return this.store().get(DATADOMAIN);
    }

    /**
     * Returns the unique id of this visual unit.
     *
     * @public
     * @return {string} Unique identifier.
     */
    id () {
        return this._id;
    }

    lockModel () {
        this._store.model.lock();
        return this;
    }

    unlockModel () {
        this._store.model.unlock();
        return this;
    }

    timeDiffsByField (...params) {
        if (params.length) {
            return this;
        }
        return this._timeDiffsByField;
    }

    /**
     * Renders the visual unit. It creates the layout and renders the axes and layers.
     *
     * @return {VisualUnit} Instance of visual unit.
     */
    render (container) {
        const config = this.config();
        const { className, defClassName, sideEffectClassName, classPrefix } = config;
        const qualifiedClassName = getQualifiedClassName(defClassName, this.id(), config.classPrefix);
        const width = this.width();
        const height = this.height();
        const containerSelection = selectElement(container).style('position', 'relative');

        this._rootSvg = makeElement(containerSelection, 'svg', [null], className)
                        .style('width', `${width}px`).style('height', `${height}px`);

        const node = this._rootSvg.node();
        setAttrs(node, {
            width,
            height,
            class: qualifiedClassName.join(' ')
        });
        renderGridLineLayers(this, node);
        renderLayers(this, node, this.layers(), {
            width,
            height
        });
        this._sideEffectGroup = createSideEffectGroup(node, `${classPrefix}-${sideEffectClassName}`);
        return this;
    }

    done () {
        return this._renderedPromise;
    }

    /**
     * Caches all the datamodels in an array from the next `data()` call on visual unit until `clearCaching()` or
     * `resetData()` is called on it.
     *
     * @public
     * @return {VisualUnit} Instance of visual unit.
     */

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

    /**
     * Clears all the previous cached data.
     *
     * @public
     * @segment VisualUnit
     * @return {VisualUnit} Instance of visual unit.
     */
    clearCaching () {
        this._cache = false;
        this.cachedData([this.cachedData()[0]]);
        return this;
    }

    /**
     * Returns the drawing information from visual unit.Drawing context contains the dimensions of unit and the svg
     * container of the visual unit.
     *
     * @public
     *
     * @return {Object} Drawing information.
     *      ```
     *          {
     *              htmlContainer: // Html container of svg container of the visual unit
     *              svgContainer: // Root svg container
     *              width: // Width of the visual unit
     *              height: // Height of the visual unit
     *              sideEffectGroup: // Svg group for drawing side effect elements.
     *              parentContainer: // Parent html container of the visual unit.
     *              xOffset: // x offset space from the starting x position of the container,
     *              yOffset: // y offset space from the starting y position of the container
     *          }
     *      ```
     */
    getDrawingContext () {
        const rootSvg = this._rootSvg && this._rootSvg.node();
        const width = this.width();
        const height = this.height();
        return {
            htmlContainer: this.mount(),
            svgContainer: rootSvg,
            width,
            height,
            sideEffectGroup: this._sideEffectGroup,
            parentContainer: this.parentContainer(),
            xOffset: 0,
            yOffset: 0
        };
    }

    /**
     * Returns the serialized configuration of visual unit.
     *
     * @return {Object} serialized configuration
     */
    serialize () {
        return {
            layers: this.layers().map(layer => layer.serialize()),
            config: this.config(),
            axes: this.store().get('axes').map(axis => axis.serialize())
        };
    }

    /**
     * Adds a new layer to the visual unit. It takes a layer definition and creates layer instances from them. It does
     * not render the layers. It returns the layer instances in an array. If the layer definition is a composite layer,
     * then multiple layer instances will be returned in the array.
     *
     * To add a layer in the unit,
     * ```
     *      unit.addLayer({
     *          name: 'bullet',
     *          mark: 'bar',
     *          encoding: {
     *              x: 'Year',
     *              y: 'Acceleration',
     *              color: 'Origin'
     *          }
     *      });
     * ```
     * @public
     * @param {Object} layerDef Definition of new layer.
     *
     * @return {Array} Array of layer instances.
     */
    addLayer (layerDef) {
        const layerName = layerDef.name;
        const layer = this.getLayerByName(layerName);
        const measurement = {
            width: this.width(),
            height: this.height()
        };

        if (layer) {
            return [layer];
        }
        const serializedDef = layerFactory.getSerializedConf(layerDef.mark, layerDef);
        const instances = Object.values(getLayerFromDef(this, serializedDef));
        this.layers().push(...instances);
        const layerAxisIndex = getLayerAxisIndex(instances, this.fields());
        this._layerAxisIndex = Object.assign(this._layerAxisIndex, layerAxisIndex);
        attachAxisToLayers(this.axes(), instances, layerAxisIndex);
        const store = { unit: this, layers: {} };
        this.layers().forEach((inst) => {
            store.layers[inst.alias()] = inst;
        });
        instances.forEach((lyr) => {
            resolveEncodingTransform(lyr, store);
            lyr.measurement(measurement);
            lyr.dataProps({
                timeDiffs: this.store().get(TIMEDIFFS)
            });
        });
        return instances;
    }

    /**
     *
     *
     *
     * @memberof VisualUnit
     */
    remove () {
        const lifeCycleManager = this._dependencies.lifeCycleManager;
        lifeCycleManager.notify({ client: this, action: 'beforeremove', formalName: 'unit' });
        this.store().unsubscribeAll();
        selectElement(this.mount()).remove();
        this.firebolt().remove();
        // Remove layers
        lifeCycleManager.notify({ client: this.layers(), action: 'beforeremove', formalName: 'layer' });
        this.layers().forEach(layer => layer.remove());
        lifeCycleManager.notify({ client: this.layers(), action: 'removed', formalName: 'layer' });
        lifeCycleManager.notify({ client: this, action: 'removed', formalName: 'unit' });
        return this;
    }

    /**
     *
     *
     * @param {*} identifiers
     *
     * @memberof VisualUnit
     */
    getDataModelFromIdentifiers (identifiers, mode, parentModel) {
        if (identifiers === null) {
            return null;
        }
        const dataModel = parentModel || this.data();
        return getDataModelFromIdentifiers(dataModel, identifiers, mode);
    }

    /**
     * Resets the data of visual unit to original data model. It also clears the cached data.
     *
     * @public
     * @segment VisualUnit
     * @return {VisualUnit} Instance of visual unit.
     */
    resetData () {
        this.data(this.cachedData()[0]);
        return this;
    }

    /**
     *
     *
     *
     * @memberof VisualUnit
     */
    getSourceInfo () {
        return {
            dimensionMeasureMap: this._dimensionMeasureMap,
            fields: this.fields(),
            data: this.data(),
            axes: this.axes()
        };
    }

    /**
     *
     *
     *
     * @memberof VisualUnit
     */
    getDefaultTargetContainer () {
        const { classPrefix, defClassName } = this.config();
        return [`.${classPrefix}-${defClassName}`];
    }

    /**
     * Returns an array of layer instances which matches the supplied mark type.
     *
     * @public
     *
     * @param {string} type Mark type of layer.
     *
     * @return {Array} Array of layer instances.
     */
    getLayersByType (type) {
        const layers = getLayersBy(this.layers(), 'type', type);
        return layers;
    }

    /**
     * Returns the layer instance which matches the supplied layer name. If no layer is found, then it returns
     * undefined.
     *
     * @public
     * @param {string} name Name of layer.
     *
     * @return {VisualUnit} Layer instance.
     */
    getLayerByName (name) {
        const layers = getLayersBy(this.layers(), 'name', name);
        return layers[0];
    }

    /**
     *
     *
     * @param {*} domain
     *
     * @memberof VisualUnit
     */
    updateAxisDomain (domain) {
        ['x', 'y'].forEach((type) => {
            const axes = this.axes()[type];
            let min = [];
            let max = [];
            let dom;
            axes && axes.forEach((axis, i) => {
                const field = this.fields()[type][i];
                dom = domain[`${this.fields()[type][i]}`];

                if (field.type() !== FieldType.DIMENSION && dom) {
                    min[i] = dom[0];
                    max[i] = dom[1];
                }
            });
            if (axes) {
                if (axes.length > 1) {
                    const axisConf = axes[0].config();
                    if (axes[0].constructor.type() === 'linear') {
                        if (axisConf.alignZeroLine) {
                            axes.forEach(axis => axis.config({
                                nice: false
                            }));
                            const adjustedDomain = getAdjustedDomain(max, min);
                            min = adjustedDomain.min;
                            max = adjustedDomain.max;
                        }

                        axes[0].updateDomainCache([min[0], max[0]]);
                        axes[1].updateDomainCache([min[1], max[1]]);
                    } else {
                        axes[0].updateDomainCache(dom);
                        axes[1].updateDomainCache(dom);
                    }
                } else {
                    axes[0].updateDomainCache(dom);
                }
            }
        });
        return this;
    }

    /**
     * Returns the point located nearest to the supplied x and y position. It returns the unique identifiers of the
     * point. This function also accepts an additional configuration `getAllPoints` inside `config` object in the third
     * argument which if set to true, then it returns the identifiers of all the points which falls on the nearest
     * x value or y value if any one of the field is a dimension. Additionally, a target property is also returned
     * which contains the identifier of the nearest point. If no nearest point is found, then it returns identifier
     * as null.
     *
     * @public
     *
     * @param {number} x X Position of the point from where nearest point is to be found.
     * @param {number} y Y Position of the point from where nearest point is to be found.
     * @param {Object} config Additional configuration options.
     * @param {boolean} config.getAllPoints If true, then returns all the points nearest to the x value or y value if
     * it is dimension.
     * @param {Object} config.data Data associated with the nearest point.
     * @return {Object} Nearest point information
     * ```
     *      {
     *          id: [['Origin'], ['USA'], ['Japan']], // Identifiers of all the points closest to the x value.
     *          target: [['Origin'], ['Japan']] // Identifier of the nearest point.
     *      }
     * ```
     */
    getNearestPoint (x, y, config) {
        let pointObj = {
            id: null
        };
        const dimValue = getNearestDimensionalValue(this, {
            x,
            y
        });

        if (dimValue !== null && config.getAllPoints) {
            pointObj.id = dimValue;
            const pointInf = this.getMarkInfFromLayers(x, y, config);
            pointObj.target = pointInf && pointInf.id ? pointInf.id : pointObj.id;
            return pointObj;
        }

        const markInf = this.getMarkInfFromLayers(x, y, config) || { id: null };
        pointObj = Object.assign({}, markInf);

        pointObj.target = markInf.id;
        return pointObj;
    }

    getMarkInfFromLayers (x, y, args) {
        const layers = this.layers();
        const len = layers.length;
        let point = null;
        // Iterate through the layers array and fetch the nearest point from each layer. If a valid
        // nearest point is found from any layer, then return that point.
        for (let i = 0; i < len; i++) {
            const layer = layers[i];
            const config = layer.config();
            if (config.interactive !== false) {
                point = layer.getNearestPoint(x, y, args);
            }
            if (point) {
                return point;
            }
        }
        return point;
    }

    /**
     * Get the information of all the marks such as x, y position and size from supplied identifiers. It
     * returns an array of points whose data matches the given identifiers.
     *
     * @public
     *
     * @param {Array|Object} identifiers Field names and their corresponding values.
     * ```
     * identifiers can be given in an array of array,
     *      ['Origin', 'Name'], // Names of the fields supplied in first array
     *      ['USA', 'ford'], // Data values of each field supplied in rest of the arrays.
     *      ['Japan', 'ford']
     * or in an object,
     *      {
     *          Origin: ['USA']
     *      }
     * ```
     * @param {Object} config Optional configurations which decides which information of the mark will
     * be retrieved.
     * @param {boolean} [config.getAllAttrs = false] If true, then returns all the information of each mark.
     * @param {boolean} [config.getBBox = false] If true, then returns the bounding box of each mark.
     *
     * @return {Array} Array of objects containing the information of each point.
     * ```
     * By default, the method returns the array of points in this structure,
     *      [
     *          {
     *              x: 20,
     *              y: 100,
     *              width: 200,
     *              height: 100
     *          }
     *      ]
     * If 'config.getAllAttrs' is true, then it returns all the information of each mark,
     *      [
     *      // Positions of mark on initial state of transition.
     *          enter: {
     *              x: 0,
     *              y: 0
     *          },
     *          // Final positions of the mark
     *          update: {
     *              x: 20,
     *              y: 10
     *          },
     *          style: // css styles of each mark
     *          source: [200, 'USA'] // Row information of each mark
     *          id: 20 // Row id of each mark
     *      ]
     * ```
     */
    getPlotPointsFromIdentifiers (identifiers, config = {}) {
        let points = [];
        let parsedIdentifiers = identifiers;
        if (identifiers === null) {
            return [];
        }
        const layers = this.layers();
        const len = layers.length;
        if (isSimpleObject(identifiers)) {
            parsedIdentifiers = [Object.keys(identifiers)];
            parsedIdentifiers = [...parsedIdentifiers, ...transposeArray(Object.values(identifiers))];
        }
        for (let i = 0; i < len; i++) {
            const layer = layers[i];
            if (layer.config().interactive !== false) {
                points = [...points, ...layer.getPointsFromIdentifiers(parsedIdentifiers, config)];
            }
        }
        return points;
    }

    /**
     * Removes the layer instance which matches the supplied layer name.
     *
     * @public
     * @param {string} name Name of layer
     *
     * @return {VisualUnit} Instance of visual unit.
     */
    removeLayerByName (name) {
        removeLayersBy('name', name);
        return this;
    }

    /**
     * Removes all the layer instances which matches the supplied mark type.
     *
     * @public
     * @param {string} type Mark type of layer.
     *
     * @return {VisualUnit} Instance of visual unit.
     */
    removeLayersByType (type) {
        removeLayersBy('type', type);
        return this;
    }

    parentContainer (...container) {
        if (container.length) {
            this._parentContainer = container[0];

            return this;
        }
        return this._parentContainer;
    }
}