toggle-corp/react-store

View on GitHub
components/Visualization/Histogram/index.js

Summary

Maintainability
A
0 mins
Test Coverage
import React, {
    PureComponent,
    Fragment,
} from 'react';
import { select, event } from 'd3-selection';
import {
    extent,
    max,
    min,
    histogram,
    thresholdSturges,
} from 'd3-array';
import { scaleLinear } from 'd3-scale';
import {
    axisBottom,
    axisLeft,
} from 'd3-axis';
import { color } from 'd3-color';

import PropTypes from 'prop-types';

import styles from './styles.scss';

import Numeral from '../../View/Numeral';
import Responsive from '../../General/Responsive';
import Float from '../../View/Float';

const emptyObject = {};

const propTypes = {
    /**
     * Array of numeric values to be represented as histogram
     */
    data: PropTypes.array.isRequired, // eslint-disable-line react/forbid-prop-types
    /**
     * Size of the parent element/component (passed by the Responsive hoc)
     */
    boundingClientRect: PropTypes.shape({
        width: PropTypes.number,
        height: PropTypes.number,
    }).isRequired,
    /**
     * Array of two colors to map height of histogram
     */
    colorRange: PropTypes.arrayOf(PropTypes.object),
    /**
     * if showAxis is true, axis is shown
     */
    showAxis: PropTypes.bool,
    /**
     * if showGrids is true, grids are shown
     */
    showGrids: PropTypes.bool,
    /**
     * modify the values to be shown in tooltip when hovered over histogram
     */
    tooltipContent: PropTypes.func,
    /**
     * Format the tick value shown in axis
     * see <a href="https://github.com/d3/d3-scale/blob/master/README.md#tickFormat">tickFormat</a>
     */
    tickFormat: PropTypes.func,
    /**
     * show tooltip if true
     */
    showTooltip: PropTypes.bool,
    /**
     * Thresholds function to use for binning
     */
    thresholds: PropTypes.func,
    /**
     * Additional classes
     */
    className: PropTypes.string,
    /**
     * Margins for the chart
     */
    margins: PropTypes.shape({
        top: PropTypes.number,
        right: PropTypes.number,
        bottom: PropTypes.number,
        left: PropTypes.number,
    }),

};

const defaultProps = {
    // TODO: use global styling variables
    colorRange: [
        color('rgba(90, 198, 198, 1)').brighter(),
        color('rgba(90, 198, 198, 1)').darker(),
    ],
    showAxis: true,
    showGrids: true,
    thresholds: thresholdSturges,
    className: '',
    tickFormat: d => (
        Numeral.renderText({
            value: d,
            precision: 1,
            normal: true,
        })
    ),
    tooltipContent: undefined,
    showTooltip: true,

    // TODO: use global styling variables
    margins: {
        top: 10,
        right: 20,
        bottom: 30,
        left: 30,
    },
};

/**
 * Histogram shows the underlying frequency distribution of continuous data.
 * The area of bar indicates the frequency of occurrences of each bin. However
 * here the width of each bin is constant so height can represent the frequency.
 */
class Histogram extends PureComponent {
    static propTypes = propTypes;

    static defaultProps = defaultProps;

    componentDidMount() {
        this.drawChart();
    }

    componentDidUpdate() {
        this.redrawChart();
    }

    onMouseOver = (d) => {
        const {
            showTooltip,
            tooltipContent,
        } = this.props;

        if (showTooltip) {
            const defaultContent = `
                <span>
                    ${Numeral.renderText({ value: d.length, precision: 0 })}
                </span>
            `;

            const content = tooltipContent ? tooltipContent(d) : defaultContent;

            this.tooltip.innerHTML = content;
            this.tooltip.style.display = 'block';
        }
    }

    onMouseMove = () => {
        const { style } = this.tooltip;

        const { width, height } = this.tooltip.getBoundingClientRect();

        const { pageX: xpos, pageY: ypos } = event;

        style.top = `${ypos - height - 10}px`;
        style.left = `${(xpos - (width / 2))}px`;
    }

    onMouseOut = () => {
        this.tooltip.style.display = 'none';
    }

    drawChart = () => {
        const {
            data,
            colorRange,
            boundingClientRect,
            margins,
            showAxis,
            showGrids,
            tickFormat,
            thresholds,
        } = this.props;

        if (!boundingClientRect.width || !data || data.length === 0) {
            return;
        }

        const {
            width: fullWidth,
            height: fullHeight,
        } = boundingClientRect;

        const {
            left = 0,
            top = 0,
            right = 0,
            bottom = 0,
        } = margins;

        const width = fullWidth - left - right;
        const height = fullHeight - top - bottom;

        if (width < 0 || height < 0) {
            return;
        }

        const dataExtent = extent(data);

        const x = scaleLinear()
            .domain(dataExtent).nice()
            .range([0, width]);

        const bins = histogram()
            .domain(x.domain())
            .thresholds(thresholds)(data);

        const y = scaleLinear()
            .domain([0, max(bins, d => d.length)]).nice()
            .range([height, 0]);

        const yMax = max(bins, d => d.length);
        const yMin = min(bins, d => d.length);

        const colorScale = scaleLinear()
            .domain([yMin, yMax])
            .range(colorRange);

        const group = select(this.svg)
            .append('g')
            .attr('transform', `translate(${left}, ${top})`);

        group
            .selectAll('.bar')
            .data(bins)
            .enter()
            .append('g')
            .attr('class', `bar ${styles.bar}`)
            .append('rect')
            .attr('x', (d) => {
                if ((x(d.x1) - x(d.x0)) <= 0) {
                    return 0;
                }
                return x(d.x0) + 1;
            })
            .attr('width', (d) => {
                if ((x(d.x1) - x(d.x0)) <= 0) {
                    return width;
                }
                return Math.max(0, x(d.x1) - x(d.x0) - 1);
            })
            .attr('y', d => y(d.length))
            .attr('height', d => y(0) - y(d.length))
            .attr('fill', d => colorScale(d.length))
            .on('mouseover', this.onMouseOver)
            .on('mousemove', this.onMouseMove)
            .on('mouseout', this.onMouseOut);

        if (showAxis) {
            group
                .append('g')
                .attr('class', `xaxis ${styles.xaxis}`)
                .attr('transform', `translate(0, ${height})`)
                .call(
                    axisBottom(x)
                        .tickFormat(tickFormat),
                );

            group
                .append('g')
                .attr('class', `yaxis ${styles.yaxis}`)
                .call(
                    axisLeft(y)
                        .tickFormat(tickFormat),
                );
        }

        if (showGrids) {
            group
                .append('g')
                .attr('class', `yaxis-grids ${styles.yaxisGrids}`)
                .call(
                    axisLeft(y)
                        .tickSizeInner(-width),
                );
        }
    };

    redrawChart = () => {
        const svg = select(this.svg);
        svg.selectAll('*').remove();
        this.drawChart();
    };

    render() {
        const {
            className: classNameFromProps,
            boundingClientRect: {
                width,
                height,
            } = emptyObject,
        } = this.props;

        const className = [
            'histogram',
            styles.histogram,
            classNameFromProps,
        ].join(' ');

        const tooltipClassName = [
            'tooltip',
            styles.tooltip,
        ].join(' ');

        return (
            <Fragment>
                <svg
                    className={className}
                    style={{
                        width,
                        height,
                    }}
                    ref={(elem) => { this.svg = elem; }}
                />
                <Float>
                    <div
                        ref={(el) => { this.tooltip = el; }}
                        className={tooltipClassName}
                    />
                </Float>
            </Fragment>
        );
    }
}

export default Responsive(Histogram);