Source: visual-layer/src/layers/point/point.js

import {
    Voronoi,
    selectElement,
    getQualifiedClassName,
    makeElement,
    FieldType,
    Scales
} from 'muze-utils';
import { BaseLayer } from '../../base-layer';
import drawSymbols from './renderer';
import { defaultConfig } from './default-config';
import { ENCODING } from '../../enums/constants';
import * as PROPS from '../../enums/props';
import {
    attachDataToVoronoi,
    getLayerColor,
    positionPoints,
    getPlotMeasurement,
    getIndividualClassName
} from '../../helpers';

import './styles.scss';

/**
 * This layer is used to create various symbols for each data point. This is commonly used in
 * scatterplot visualizations. The mark type of this layer is ```point```.
 *
 * @public
 *
 * @class
 * @module PointLayer
 * @extends BaseLayer
 */
export default class PointLayer extends BaseLayer {

    /**
     * Creates an instance of PointLayer.
     * @param {*} args
     * @memberof PointLayer
     */
    constructor (...args) {
        super(...args);
        this._voronoi = new Voronoi();
        this._bandScale = Scales.band();
    }

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

    /**
     * Returns the default configuration of the point layer
     * @return {Object} Default configuration of the point layer
     */
    static defaultConfig () {
        return defaultConfig;
    }

    static defaultPolicy (conf, userConf) {
        const config = BaseLayer.defaultPolicy(conf, userConf);
        const encoding = config.encoding;
        const transform = config.transform;
        const colorField = encoding.color && encoding.color.field;

        if (colorField) {
            transform.groupBy = colorField;
        }
        return config;
    }

    /**
     *
     *
     * @static
     *
     * @memberof PointLayer
     */
    static formalName () {
        return 'point';
    }

    /**
     *
     *
     * @static
     *
     * @memberof PointLayer
     */
    static drawFn () {
        return drawSymbols;
    }

    /**
     * Generates an array of objects containing x, y, width and height of the points from the data
     * @param  {Array.<Array>} data Data Array
     * @param  {Object} encoding  Config
     * @param  {Object} axes     Axes object
     * @return {Array.<Object>}  Array of points
     */
    translatePoints (data, encoding, axes, config = {}) {
        let points = [];
        const {
            size: sizeEncoding,
            shape: shapeEncoding,
            color: colorEncoding,
            x,
            y
        } = encoding;
        const sizeField = sizeEncoding.field;
        const sizeValue = sizeEncoding.value;
        const shapeField = shapeEncoding.field;
        const xField = x.field;
        const yField = y.field;
        const { size: sizeAxis, shape: shapeAxis } = axes;
        const fieldsConfig = this.data().getFieldsConfig();
        const isXDim = fieldsConfig[xField] && fieldsConfig[xField].def.type === FieldType.DIMENSION;
        const isYDim = fieldsConfig[yField] && fieldsConfig[yField].def.type === FieldType.DIMENSION;
        const key = isXDim ? ENCODING.X : (isYDim ? ENCODING.Y : null);
        const colorField = colorEncoding && colorEncoding.field;
        const colorFieldIndex = fieldsConfig[colorField] && fieldsConfig[colorField].index;
        const measurement = this._store.get(PROPS.MEASUREMENT);
        const shapeFieldIndex = fieldsConfig[shapeField] && fieldsConfig[shapeField].index;
        const sizeFieldIndex = fieldsConfig[sizeField] && fieldsConfig[sizeField].index;
        const colorAxis = axes.color;
        const { x: offsetX, y: offsetY } = config.offset;

        for (let i = 0, len = data.length; i < len; i++) {
            const d = data[i];
            const row = d._data;
            const size = sizeValue instanceof Function ? sizeValue(d, i) : sizeAxis.getSize(row[sizeFieldIndex]);
            const shape = shapeAxis.getShape(row[shapeFieldIndex]);

            let [xPx, yPx] = [ENCODING.X, ENCODING.Y].map((type) => {
                const value = d[type] === null ? undefined : d[type];
                const measure = type === ENCODING.X ? measurement.width : measurement.height;
                return !encoding[type].field ? measure / 2 : axes[type].getScaleValue(value);
            });

            xPx += offsetX;
            yPx += offsetY;

            const { color, rawColor } = getLayerColor({ datum: d, index: i },
                { colorEncoding, colorAxis, colorFieldIndex });

            const style = {
                fill: color,
                stroke: color
            };

            if (!isNaN(xPx) && !isNaN(yPx)) {
                const point = {
                    enter: {
                        x: xPx,
                        y: yPx
                    },
                    update: {
                        x: xPx,
                        y: yPx
                    },
                    shape,
                    size: Math.abs(size),
                    meta: {
                        stateColor: {},
                        originalColor: rawColor,
                        colorTransform: {}
                    },
                    style,
                    _data: row,
                    _id: d._id,
                    source: d._data,
                    rowId: d._id
                };
                point.className = getIndividualClassName(d, i, data, this);
                points.push(point);
                this.cachePoint(d[key], point);
            }
        }
        points = positionPoints(this, points);
        return points;
    }

    /**
     * Renders the plot in the given container.
     *
     * @param  {SVGElement} container SVGElement which will hold the plot
     * @return {BarLayer} Instance of bar layer
     */
    render (container) {
        let maxSize = 0;
        let seriesClassName;
        const config = this.config();
        const keys = this._store.get(PROPS.TRANSFORMED_DATA).map(d => d.key);
        const { transition, className, defClassName, classPrefix } = config;
        const normalizedData = this._store.get(PROPS.NORMALIZED_DATA);
        const containerSelection = selectElement(container);
        const qualifiedClassName = getQualifiedClassName(defClassName, this.id(), classPrefix);
        this._points = [];
        this._pointMap = {};

        containerSelection.classed(qualifiedClassName.join(' '), true).classed(className, true);

        this._points = this.generateDataPoints(normalizedData, keys);

        makeElement(container, 'g', this._points, null, {
            update: (group, points) => {
                maxSize = Math.max(maxSize, ...points.map(d => d.size));
                seriesClassName = `${qualifiedClassName[0]}`;
                this.constructor.drawFn()({
                    container: group.node(),
                    points,
                    className: seriesClassName,
                    transition,
                    keyFn: d => d._id
                });
            }
        }, data => data[0]._id);
        this._maxSize = Math.sqrt(maxSize / Math.PI) * 2;
        attachDataToVoronoi(this._voronoi, this._points);
        return this;
    }

    generateDataPoints (normalizedData, keys) {
        const encoding = this.config().encoding;
        const axes = this.axes();
        const [widthMetrics, heightMetrics] = getPlotMeasurement(this, keys);
        const offsetXValues = widthMetrics.offsetValues || [];
        const offsetYValues = heightMetrics.offsetValues || [];
        return normalizedData.map((dataArr, i) => {
            const measurementConf = this.getMeasurementConfig(offsetXValues[i], offsetYValues[i], widthMetrics.span,
                heightMetrics.span);
            return this.translatePoints(dataArr, encoding, axes, measurementConf);
        }).filter(d => d.length);
    }

    getMeasurementConfig (offsetX, offsetY, widthSpan, heightSpan) {
        return {
            offset: {
                x: (offsetX || 0) + widthSpan / 2,
                y: (offsetY || 0) + heightSpan / 2
            },
            span: {
                x: widthSpan,
                y: heightSpan
            }
        };
    }

    /**
     * Gets the nearest point from a position.
     * @param {number} x x position
     * @param {number} y y position
     * @return {Object} Point details
     */
    getNearestPoint (x, y) {
        const distanceLimit = Math.max(this._maxSize, this.config().nearestPointThreshold);

        if (!this.data()) {
            return null;
        }

        const point = this._voronoi.find(x, y, distanceLimit);
        const dimensions = point && point.data.data.update;
        const radius = point ? Math.sqrt(point.data.data.size / Math.PI) : 0;

        if (point) {
            const { _data, _id } = point.data.data;
            const identifiers = this.getIdentifiersFromData(_data, _id);
            return {
                id: identifiers,
                dimensions: [{
                    x: dimensions.x,
                    y: dimensions.y,
                    width: radius,
                    height: radius
                }],
                layerId: this.id()
            };
        }
        return null;
    }
}