toggle-corp/react-store

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

Summary

Maintainability
A
0 mins
Test Coverage
import React, {
    Fragment,
    PureComponent,
} from 'react';
import SvgSaver from 'svgsaver';
import { PropTypes } from 'prop-types';
import { schemeSet2 } from 'd3-scale-chromatic';
import {
    select,
    event,
    mouse,
} from 'd3-selection';
import {
    scaleLinear,
    scaleOrdinal,
} from 'd3-scale';
import {
    area,
    stack,
    stackOffsetWiggle,
    stackOrderInsideOut,
    curveBasis,
} from 'd3-shape';
import { keys } from 'd3-collection';
import {
    extent,
    min,
    max,
} from 'd3-array';
import { areaLabel } from 'd3-area-label';
import { axisBottom } from 'd3-axis';
import { getColorOnBgColor } from '@togglecorp/fujs';

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

import { getStandardFilename } from '../../../utils/common';

import styles from './styles.scss';

const propTypes = {
    /**
     * Size of the parent element/component (passed by the Responsive hoc)
     */
    boundingClientRect: PropTypes.shape({
        width: PropTypes.number,
        height: PropTypes.number,
    }).isRequired,
    /**
     * The data to be visualized.
     * Array of categorical data grouped together where is group has a group identifier.
     * Example data: [{ state: 'Province 1', river: 10, hills: 20 },
     * { state: 'Province 2', river: 1, hills: 3}]
     */
    data: PropTypes.arrayOf(
        PropTypes.shape({
            name: PropTypes.string,
        }),
    ).isRequired,
    /**
     * Handle save functionality
     */
    setSaveFunction: PropTypes.func,
    /**
     * Additional css classes passed from parent
     */
    className: PropTypes.string,
    /**
     * Select the identifier for group
     */
    labelSelector: PropTypes.func.isRequired,
    /**
     * Name of the group identifier key
     */
    labelName: PropTypes.string.isRequired,
    /**
     * Array of colors as hex color codes.
     * It is used if colors are not provided through data.
     */
    colorScheme: PropTypes.arrayOf(PropTypes.string),
    /**
     * Margins for the chart
     */
    margins: PropTypes.shape({
        top: PropTypes.number,
        right: PropTypes.number,
        bottom: PropTypes.number,
        left: PropTypes.number,
    }),
};

const defaultProps = {
    setSaveFunction: () => {},
    className: '',
    colorScheme: schemeSet2,
    margins: {
        top: 10,
        right: 10,
        bottom: 50,
        left: 50,
    },
};
/**
 * StreamGraph is a variation of Stacked Bar Chart. The variables are  plotted against a
 * fixed axis and the values are displaced around a variying central baseline.
 * It helps to visualize high volume data and changes in data values over time of
 * different categories. StreamGraph are used to give general view of the data.
 */
class StreamGraph extends PureComponent {
    static propTypes = propTypes;

    static defaultProps = defaultProps;

    constructor(props) {
        super(props);
        if (props.setSaveFunction) {
            props.setSaveFunction(this.save);
        }
    }

    componentDidMount() {
        this.drawChart();
    }

    componentDidUpdate() {
        this.redrawChart();
    }

    onMouseOver = (d, i) => {
        const {
            group,
        } = this;

        group
            .selectAll('.layer')
            .transition()
            .duration(250)
            .attr('opacity', (e, j) => (j === i ? 1 : 0.6))
            .attr('storke-width', '0px');

        group
            .select('.mouse-line')
            .style('opacity', '1');
    }

    onMouseMove = (d, i, nodes) => {
        const {
            x,
            labels,
            tooltip,
            group,
            height,
        } = this;

        const {
            data,
            labelSelector,
        } = this.props;

        const mouseXpos = mouse(nodes[i])[0];
        const xValue = Math.floor(x.invert(mouseXpos));

        const labelData = data.filter(row => labelSelector(row) === xValue)[0] || [];
        let out = `<span>${xValue}</span>`;
        labels.forEach((label) => {
            const value = labelData[label] || 0;
            out += `<span class=${styles.label}>${label}: ${value}</span>`;
        });

        group
            .select('.mouse-line')
            .attr('d', `M${mouseXpos},${height} ${mouseXpos},${0}`);

        tooltip
            .html(out)
            .style('display', 'block')
            .style('top', `${event.pageY - 30}px`)
            .style('left', `${event.pageX + 20}px`);

        select(nodes[i])
            .classed('hover', true);
    }

    onMouseOut = (d, i, nodes) => {
        const {
            group,
            tooltip,
        } = this;

        group
            .selectAll('.layer')
            .transition()
            .duration(250)
            .attr('opacity', 1);

        group
            .select('.mouse-line')
            .style('opacity', 0);

        tooltip
            .style('display', 'none');

        select(nodes[i])
            .classed('hover', 'false');
    }

    save = () => {
        const svgsaver = new SvgSaver();
        const svg = select(this.svg);
        svgsaver.asSvg(svg.node(), `${getStandardFilename('stream-graph', 'graph')}.svg`);
    }

    init = () => {
        const {
            boundingClientRect,
            margins,
            data,
            labelName,
            labelSelector,
            colorScheme,
        } = this.props;

        const {
            width,
            height,
        } = boundingClientRect;

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

        this.width = width - left - right;
        this.height = height - top - bottom;

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

        this.labels = keys(data[0]).filter(d => d !== labelName);
        const values = data.map(d => labelSelector(d));

        this.series = stack()
            .keys(this.labels)
            .order(stackOrderInsideOut)
            .offset(stackOffsetWiggle)(data);

        this.x = scaleLinear()
            .domain(extent(values))
            .range([0, this.width]);

        const stackMin = row => min(row, d => d[0]);
        const stackMax = row => max(row, d => d[1]);

        this.y = scaleLinear()
            .domain([min(this.series, stackMin), max(this.series, stackMax)])
            .range([this.height, 0])
            .nice();

        this.colors = scaleOrdinal()
            .domain(this.labels)
            .range(colorScheme);

        this.size = area()
            .x(d => this.x(labelSelector(d.data)))
            .y0(d => this.y(d[0]))
            .y1(d => this.y(d[1]))
            .curve(curveBasis);

        this.tooltip = select(this.tip);
    }

    drawChart = () => {
        const {
            boundingClientRect,
            data,
        } = this.props;

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

        this.init();
        const {
            group,
            size,
            colors,
            series,
            x,
            onMouseOver,
            onMouseMove,
            onMouseOut,
        } = this;

        group
            .selectAll('.layer')
            .data(series)
            .enter()
            .append('path')
            .attr('d', size)
            .attr('class', 'layer')
            .style('fill', d => colors(d.key))
            .attr('stroke', t => colors(t.key))
            .attr('stroke-width', '2px');

        group
            .selectAll('.area-label')
            .data(series)
            .enter()
            .append('text')
            .attr('class', 'area-label')
            .text(d => d.key)
            .style('fill', d => getColorOnBgColor(colors(d.key)))
            .style('fill-opacity', 0.7)
            .style('pointer-events', 'none')
            .attr('transform', areaLabel(size));

        group
            .append('g')
            .attr('class', styles.xAxis)
            .attr('transform', `translate(0, ${this.height})`)
            .call(axisBottom(x).tickSize(0).tickPadding(6));

        group
            .selectAll('.layer')
            .attr('opacity', 1)
            .style('cursor', 'pointer')
            .on('mouseover', onMouseOver)
            .on('mousemove', onMouseMove)
            .on('mouseout', onMouseOut);

        group
            .append('g')
            .append('path')
            .attr('class', 'mouse-line')
            .attr('pointer-events', 'none')
            .style('stroke', '#d3d3d3')
            .style('stroke-width', '1px')
            .style('opacity', '0');
    }

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

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

        const svgClassName = [
            'stream-graph',
            styles.streamGraph,
            className,
        ].join(' ');

        const tooltipClassName = [
            'stream-graph-tooltip',
            styles.streamGraphTooltip,
        ].join(' ');

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

export default Responsive(StreamGraph);