toggle-corp/react-store

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

Summary

Maintainability
A
0 mins
Test Coverage
import React, {
    PureComponent,
    Fragment,
} from 'react';
import { select, event } from 'd3-selection';
import { hierarchy, partition } from 'd3-hierarchy';
import { arc } from 'd3-shape';
import { interpolateArray } from 'd3-interpolate';
import { scaleLinear, scaleOrdinal } from 'd3-scale';
import { PropTypes } from 'prop-types';
import { schemePaired } from 'd3-scale-chromatic';
import { path } from 'd3-path';
import SvgSaver from 'svgsaver';
import { getColorOnBgColor, randomString, doesObjectHaveNoData } from '@togglecorp/fujs';

import Responsive from '../../General/Responsive';
import { getStandardFilename } from '../../../utils/common';
import Float from '../../View/Float';

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,
    /**
     * Hierarchical data structure that can be computed to form a hierarchical layout
     * <a href="https://github.com/d3/d3-hierarchy">d3-hierarchy</a>
     */
    data: PropTypes.shape({
        name: PropTypes.string,
    }).isRequired,
    /**
     * Handle save functionality
     */
    setSaveFunction: PropTypes.func,
    /**
     * Accessor function to return children of node
     */
    childrenSelector: PropTypes.func,
    /**
     * Select label for each node
     */
    labelSelector: PropTypes.func.isRequired,
    /**
     * Modify the tooltip content
     */
    tooltipContent: PropTypes.func,
    /**
     * Select a color for each node
     */
    colorSelector: PropTypes.func,
    /**
     * Select the value of each node
     */
    valueSelector: PropTypes.func.isRequired,
    /**
     * if true, a tooltip is shown
     */
    showTooltip: PropTypes.bool,
    /**
     * Array of colors as hex color codes
     */
    colorScheme: PropTypes.arrayOf(PropTypes.string),
    /**
     * Additional css classes passed from parent
     */
    className: PropTypes.string,
    /**
     * Margins for the chart
     */
    margins: PropTypes.shape({
        top: PropTypes.number,
        right: PropTypes.number,
        bottom: PropTypes.number,
        left: PropTypes.number,
    }),
};

const defaultProps = {
    setSaveFunction: () => {},
    childrenSelector: d => d.children,
    colorScheme: schemePaired,
    colorSelector: undefined,
    tooltipContent: undefined,
    showTooltip: true,
    className: '',
    margins: {
        top: 0,
        right: 0,
        bottom: 0,
        left: 0,
    },
};

const twoPi = 2 * Math.PI;
const tooltipOffset = { x: 10, y: 10 };
/**
 * SunBurst shows hierarchical data as a series of rings and slices. Each slice represents a
 * node of the tree structure. SunBurst can be thought as a multi level pie chart.
  */
class SunBurst 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();
    }

    getColor = (d) => {
        const {
            labelSelector,
            colorSelector,
        } = this.props;

        if (colorSelector) {
            return colorSelector(d);
        }
        return this.colors(labelSelector(d.children ? d.data : d.parent.data));
    }

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

    calculateBounds = () => {
        const {
            margins,
            boundingClientRect,
        } = this.props;

        const {
            width,
            height,
        } = boundingClientRect;

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

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

        this.svgGroupTransformation = `translate(
            ${(this.width) / 2},
            ${(this.height) / 2}
        )`;
    }

    init = () => {
        const { colorScheme } = this.props;

        this.calculateBounds();

        this.radius = Math.min(this.width, this.height) / 2;

        this.x = scaleLinear()
            .range([0, twoPi])
            .clamp(true);

        this.y = scaleLinear()
            .range([0, this.radius]);

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

        this.arch = arc()
            .startAngle(d => this.x(d.x0))
            .endAngle(d => this.x(d.x1))
            .innerRadius(d => Math.max(0, this.y(d.y0)))
            .outerRadius(d => Math.max(0, this.y(d.y1)));
    }

    middleArcLine = (d) => {
        const halfPi = Math.PI / 2;
        const angles = [this.x(d.x0) - halfPi, this.x(d.x1) - halfPi];
        const r = Math.max(0, (this.y(d.y0) + this.y(d.y1)) / 2);

        const middleAngle = (angles[1] + angles[0]) / 2;
        const invertDirection = middleAngle > 0 && middleAngle < Math.PI;
        if (invertDirection) { angles.reverse(); }

        const paths = path();
        paths.arc(0, 0, r, angles[0], angles[1], invertDirection);
        return paths.toString();
    }

    filterText = (d) => {
        if (d.depth === 0) {
            return false;
        }
        const CHAR_SPACE = 6;
        const deltaAngle = this.x(d.x1) - this.x(d.x0);
        const r = Math.max(0, (this.y(d.y0) + this.y(d.y1)) / 2);
        const perimeter = r * deltaAngle;

        return d.data.name.length * CHAR_SPACE < perimeter;
    }

    handleClick = (d = { x0: 0, x1: 1, y0: 0, y1: 1 }) => {
        const transitions = select(this.svg)
            .transition()
            .duration(750)
            .tween('scale', () => {
                const xd = interpolateArray(this.x.domain(), [d.x0, d.x1]);
                const yd = interpolateArray(this.y.domain(), [d.y0, 1]);
                const yr = interpolateArray(this.y.range(), [d.y0 ? 20 : 0, this.radius]);
                return (t) => { this.x.domain(xd(t)); this.y.domain(yd(t)).range(yr(t)); };
            });

        transitions
            .selectAll('path.main-arc')
            .attrTween('d', t => () => this.arch(t));

        transitions
            .selectAll('path.hidden-arc')
            .attrTween('d', t => () => this.middleArcLine(t));

        transitions
            .selectAll('text')
            .attrTween('display', t => () => (this.filterText(t) ? null : 'none'));

        this.moveStackToFront(d);
    }

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

        if (showTooltip) {
            const defaultTooltipContent = `
            <span class="${styles.label}">
                 ${labelSelector(d.data) || ''}
            </span>
            <span class="${styles.value}">
                 ${d.value || ''}
            </span>`;

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

            this.tooltip.innerHTML = content;

            const { style } = this.tooltip;
            style.display = 'block';
        }
    }

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

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

        style.top = `${event.pageY - height - tooltipOffset.y}px`;
        style.left = `${event.pageX - (width / 2)}px`;
    }

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

    moveStackToFront = (t) => {
        select(this.svg)
            .selectAll('.slice')
            .filter(d => d === t)
            .each((d, i, nodes) => {
                nodes[i].parentNode.appendChild(nodes[i]);
                if (d.parent) {
                    this.moveStackToFront(d.parent);
                }
            });
    }

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

        if (!boundingClientRect.width || doesObjectHaveNoData(data)) {
            return;
        }

        this.init();
        const uniqueId = randomString(16);

        const {
            width,
            height,
        } = boundingClientRect;

        const svg = select(this.svg);

        const group = svg
            .attr('width', width)
            .attr('height', height)
            .on('click', this.handleClick)
            .append('g')
            .attr('transform', this.svgGroupTransformation);

        const root = hierarchy(data, childrenSelector)
            .sum(d => valueSelector(d));
        const partitions = partition()(root);
        const slicesData = partitions.descendants();

        const slices = group
            .selectAll('g.slice')
            .data(slicesData);

        slices.exit().remove();

        const newSlice = slices
            .enter()
            .append('g')
            .attr('class', 'slice')
            .on('click', (d) => {
                event.stopPropagation();
                this.handleClick(d);
            });

        newSlice
            .append('path')
            .attr('class', 'main-arc')
            .style('fill', d => this.getColor(d))
            .attr('d', this.arch)
            .style('cursor', 'pointer')
            .style('stroke-width', d => d.height + 2)
            .style('stroke', 'white')
            .on('mouseover', this.handleArcMouseOver)
            .on('mousemove', this.handleArcMouseMove)
            .on('mouseout', this.handleArcMouseOut)
            .style('fill', d => this.colors(labelSelector(d.children ? d.data : d.parent.data)))
            .style('cursor', 'pointer')
            .on('click', d => this.handleClick(d));

        newSlice
            .append('path')
            .attr('class', 'hidden-arc')
            .style('fill', 'none')
            .attr('id', (_, i) => `${uniqueId}-hiddenArc${i}`)
            .attr('d', this.middleArcLine);

        const text = newSlice
            .append('text')
            .attr('display', d => (this.filterText(d) ? null : 'none'))
            .style('pointer-events', 'none');

        text
            .append('textPath')
            .attr('startOffset', '50%')
            .attr('text-anchor', 'middle')
            .attr('xlink:href', (_, i) => `#${uniqueId}-hiddenArc${i}`)
            .text(d => labelSelector(d.data))
            .style('fill', (d) => {
                const colorBg = this.getColor(d);
                return getColorOnBgColor(colorBg);
            });
    }

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

    render() {
        const { className } = this.props;
        const svgClassName = [
            'sunburst',
            className,
        ].join(' ');

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

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

export default Responsive(SunBurst);