toggle-corp/react-store

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

Summary

Maintainability
A
0 mins
Test Coverage
import React, {
    PureComponent,
    Fragment,
} from 'react';
import { select, event } from 'd3-selection';
import { linkVertical } from 'd3-shape';
import { hierarchy, tree } from 'd3-hierarchy';
import { zoom } from 'd3-zoom';
import { PropTypes } from 'prop-types';
import { getColorOnBgColor, doesObjectHaveNoData } from '@togglecorp/fujs';

import Icon from '../../General/Icon';
import Responsive from '../../General/Responsive';

import styles from './styles.scss';

const propTypes = {
    /**
     * 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,
    }),
    /**
     * Size of the parent element/component (passed by the Responsive hoc)
     */
    boundingClientRect: PropTypes.shape({
        width: PropTypes.number,
        height: PropTypes.number,
    }).isRequired,
    /**
     * Selected data element
     */
    value: PropTypes.string,
    /**
     * Accessor function to return array of data representing the children
     */
    childrenSelector: PropTypes.func,
    /**
     * Access the individual label of each data element
     */
    labelSelector: PropTypes.func.isRequired,
    /**
     * Access the id of each data element
     */
    idSelector: PropTypes.func.isRequired,
    /**
     * Handle selection of nodes
     */
    onSelection: PropTypes.func,
    /**
     * Cluster layout's node size
     * <a href="https://github.com/d3/d3-hierarchy#cluster_nodeSize">nodeSize</a>
     */
    nodeSize: PropTypes.arrayOf(PropTypes.number),
    /**
     * if true no click events on nodes
     */
    disabled: PropTypes.bool,
    /**
     *  Default color for nodes
     */
    fillColor: PropTypes.string,
    /**
     * Nodes color when selected
     */
    selectColor: PropTypes.string,
    /**
     * Additional class name for styling
     */
    className: PropTypes.string,
    /**
     *  Margin object with properties for the four sides (clockwise from top)
     */
    margins: PropTypes.shape({
        top: PropTypes.number,
        right: PropTypes.number,
        bottom: PropTypes.number,
        left: PropTypes.number,
    }),
};

const defaultProps = {
    data: [],
    value: undefined,
    childrenSelector: d => d.children,
    onSelection: () => {},
    nodeSize: [150, 300],
    disabled: false,
    fillColor: '#ffffff',
    selectColor: '#afeeee',
    className: '',
    margins: {
        top: 0,
        right: 0,
        bottom: 0,
        left: 0,
    },
};

const rectWidth = 30;

/**
 * Organigram shows the structure and relationships of nodes as a hierarchy.
 */
class Organigram extends PureComponent {
    static propTypes = propTypes;

    static defaultProps = defaultProps;

    constructor(props) {
        super(props);
        Object.assign(this, { x: 0, y: 0, k: 1 });
    }

    state = {
        selected: this.props.value,
    };

    componentDidMount() {
        this.drawChart();
    }

    // eslint-disable-next-line camelcase
    UNSAFE_componentWillReceiveProps(nextProps) {
        const { value } = this.props;
        if (value !== nextProps.value) {
            this.setState({
                selected: nextProps.value,
            });
        }
    }

    componentDidUpdate() {
        this.redrawChart();
    }

    setContext = (width, height, margins) => {
        const {
            top,
            left,
        } = margins;

        const group = select(this.svg)
            .call(zoom().on('zoom', () => {
                const { x, y, k } = event.transform;
                Object.assign(this, { x, y, k });
                group
                    .attr('transform', `translate(${x + left + (width / 2)}, ${top + rectWidth + y}) scale(${k})`);
            }))
            .append('g')
            .attr('transform',
                `translate(${this.x + left + (width / 2)}, ${top + rectWidth + this.y}) scale(${this.k})`);

        return group;
    }

    addDropShadow = (svg) => {
        const defs = svg.append('defs');

        const filter = defs
            .append('filter')
            .attr('id', 'drop-shadow')
            .attr('height', '130%');

        filter
            .append('feGaussianBlur')
            .attr('in', 'SourceAlpha')
            .attr('stdDeviation', 2)
            .attr('result', 'blur');

        filter
            .append('feOffset')
            .attr('in', 'blur')
            .attr('dx', 3)
            .attr('dy', 3)
            .attr('result', 'offsetBlur');

        filter
            .append('feFlood')
            .attr('flood-color', '#e0e0e0')
            .attr('flood-opacity', 1)
            .attr('result', 'offsetColor');

        filter
            .append('feComposite')
            .attr('in', 'offsetColor')
            .attr('in2', 'offsetBlur')
            .attr('operator', 'in')
            .attr('result', 'offsetBlur');

        const feMerge = filter.append('feMerge');

        feMerge
            .append('feMergeNode')
            .attr('in', 'offsetBlur');
        feMerge
            .append('feMergeNode')
            .attr('in', 'SourceGraphic');
    }

    toggleSelection = (data) => {
        const isSelected = this.isSelected(data);
        if (isSelected) {
            this.removeSelection();
        } else {
            this.addSelection(data);
        }
    }

    addSelection = (data) => {
        const { idSelector } = this.props;

        const newSelection = idSelector(data);
        this.setState({
            selected: newSelection,
        });

        this.props.onSelection(newSelection);
    }

    removeSelection = () => {
        this.setState({
            selected: undefined,
        }, () => {
            this.props.onSelection(undefined);
        });
    }

    isSelected = data => this.props.idSelector(data) === this.state.selected;

    colorExtractor = (item) => {
        const {
            selectColor,
            fillColor,
        } = this.props;

        const isSelected = this.isSelected(item.data);
        return isSelected ? selectColor : fillColor;
    }

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

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

        let {
            width,
            height,
        } = boundingClientRect;

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

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

        const {
            setContext,
            colorExtractor,
            addDropShadow,
            toggleSelection,
        } = this;

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

        addDropShadow(select(this.svg));
        const group = setContext(width, height, margins);
        const treemap = tree()
            .nodeSize(nodeSize)
            .separation((a, b) => (a.parent === b.parent ? 1 : 1.5));

        const root = hierarchy(data, childrenSelector);
        const treeData = treemap(root);
        const links = treeData.links();
        const points = treeData.descendants();
        const link = linkVertical()
            .x(d => d.x)
            .y(d => d.y);

        group
            .selectAll('.link')
            .data(links)
            .enter()
            .append('path')
            .attr('class', 'link')
            .attr('fill', 'none')
            .attr('stroke', '#ccc')
            .attr('d', link);

        const node = group
            .selectAll('.node')
            .data(points)
            .enter()
            .append('g')
            .attr('class', d => `node ${d.children ? 'node--internal' : 'node-leaf'}`)
            .attr('transform', d => `translate(${d.x}, ${d.y})`);

        node
            .append('rect')
            .attr('class', 'box')
            .attr('rx', 3)
            .attr('ry', 3)
            .style('fill', colorExtractor)
            .style('stroke', '#bdbdbd')
            .style('filter', 'url(#drop-shadow)')
            .style('cursor', 'pointer');

        node
            .append('text')
            .attr('dy', '.35em')
            .style('text-anchor', 'middle')
            .style('pointer-events', 'none')
            .text(d => labelSelector(d.data))
            .style('fill', d => getColorOnBgColor(colorExtractor(d)));

        const boxPadding = 10;

        node
            .selectAll('.box')
            .attr('width', (d, i, nodes) => select(nodes[i]).node().parentNode.getBBox().width + boxPadding)
            .attr('height', (d, i, nodes) => select(nodes[i]).node().parentNode.getBBox().height + boxPadding)
            .attr('x', (d, i, nodes) => select(nodes[i]).node().parentNode.getBBox().x - (boxPadding / 2))
            .attr('y', (d, i, nodes) => select(nodes[i]).node().parentNode.getBBox().y - (boxPadding / 2));

        if (!disabled) {
            node
                .selectAll('.box')
                .on('click', (cell) => {
                    toggleSelection(cell.data);
                });
        }
    }

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

        const svgClassName = [
            'organigram',
            styles.organigram,
            className,
        ].join(' ');

        return (
            <Fragment>
                <svg
                    ref={(elem) => { this.svg = elem; }}
                    className={svgClassName}
                    style={{
                        width,
                        height,
                    }}
                />
                <Icon
                    className={styles.info}
                    name="info"
                    title="Use mouse to pan and zoom"
                />
            </Fragment>
        );
    }
}

export default Responsive(Organigram);