toggle-corp/react-store

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

Summary

Maintainability
A
0 mins
Test Coverage
import React, {
    Fragment,
} from 'react';
import {
    select,
    event,
} from 'd3-selection';
import {
    hierarchy,
    tree,
} from 'd3-hierarchy';
import { scaleOrdinal } from 'd3-scale';
import { easeSinInOut } from 'd3-ease';
import { schemePaired } from 'd3-scale-chromatic';
import { zoom } from 'd3-zoom';
import { PropTypes } from 'prop-types';
import SvgSaver from 'svgsaver';
import { doesObjectHaveNoData } from '@togglecorp/fujs';

import Icon from '../../General/Icon';
import Responsive from '../../General/Responsive';
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,
    /**
     * 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.array, // eslint-disable-line react/forbid-prop-types
    /**
     * 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,
    /**
     * Array of colors as hex color codes
     */
    colorScheme: PropTypes.arrayOf(PropTypes.string),
    /**
     * Cluster layout's node size
     * <a href="https://github.com/d3/d3-hierarchy#cluster_nodeSize">nodeSize</a>
     */
    nodeSize: PropTypes.arrayOf(PropTypes.number),
    /**
     * 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 = {
    data: [],
    setSaveFunction: () => {},
    childrenSelector: d => d.children,
    colorScheme: schemePaired,
    nodeSize: [50, 300],
    className: '',
    margins: {
        top: 0,
        right: 0,
        bottom: 0,
        left: 0,
    },
};

/**
 * CollapsibleTree is a tree diagram showing the hierarchical structure of the data
 */
class CollapsibleTree extends React.PureComponent {
    static propTypes = propTypes;

    static defaultProps = defaultProps;

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

    componentDidMount() {
        this.drawChart();
    }

    componentDidUpdate() {
        this.redrawChart();
    }

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

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

        return group;
    }

    setupChart = () => {
        const {
            data,
            childrenSelector,
            boundingClientRect,
            colorScheme,
            nodeSize,
            margins,
        } = this.props;

        const { width, height } = boundingClientRect;
        const {
            top,
            right,
            bottom,
            left,
        } = margins;

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

        this.trees = tree().nodeSize(nodeSize);
        this.root = hierarchy(data, childrenSelector);
        this.root.x0 = height / 2;
        this.root.y0 = 0;
        this.colors = scaleOrdinal().range(colorScheme);
        this.group = this.setContext(this.width, this.height, margins);
        this.duration = 0;
    }

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

    topicColors = (node) => {
        const { labelSelector } = this.props;
        // let color = this.colors(0);
        let color;
        if (node.depth === 0 || node.depth === 1) {
            color = this.colors(labelSelector(node.data));
        } else {
            color = this.topicColors(node.parent);
        }
        return color;
    }

    diagonal = (s, d) => {
        const path = `M ${s.y} ${s.x}
            C ${(s.y + d.y) / 2} ${s.x},
              ${(s.y + d.y) / 2} ${d.x},
              ${d.y} ${d.x}`;

        return path;
    }

    click = (d) => {
        if (d.children) {
            d.childrens = d.children; // eslint-disable-line no-param-reassign
            d.children = null; // eslint-disable-line no-param-reassign
        } else {
            d.children = d.childrens; // eslint-disable-line no-param-reassign
            d.childrens = null; // eslint-disable-line no-param-reassign
        }
        this.duration = 500;
        this.update(d);
    }

    addNodes = (group, source, nodes) => {
        const { labelSelector } = this.props;

        let i = 0;
        const node = group
            .selectAll('g.node')
            .data(nodes, (d) => {
                if (d.id) {
                    return d.id;
                }
                i += 1;
                d.id = i; // eslint-disable-line no-param-reassign
                return d.id;
            });
        const nodeEnter = node
            .enter()
            .append('g')
            .attr('class', 'node')
            .attr('cursor', 'pointer')
            .attr('transform', `translate(${source.y0}, ${source.x0})`)
            .on('click', this.click);

        nodeEnter
            .append('circle')
            .attr('class', 'node')
            .attr('r', 0)
            .style('stroke', '#fff')
            .style('stroke-width', '2px')
            .style('fill', this.topicColors);

        nodeEnter
            .append('text')
            .attr('dy', '.35em')
            .attr('fill-opacity', 0)
            .attr('x', 0)
            .attr('y', -12) // d => (d.children || d.childrens ? 16 : 0))
            .attr('text-anchor', 'middle') // d => (d.children ? 'end' : 'start'))
            .text(d => labelSelector(d.data));


        group
            .select('text')
            .call((d) => {
                const { margins } = this.props;
                const {
                    top,
                    left,
                } = margins;
                const {
                    x = 0,
                    y = 0,
                    k = 1,
                } = this;
                const rootTextLength = d.node().getComputedTextLength() || 1;
                const translateX = x < rootTextLength ? rootTextLength : 0;
                group
                    .transition()
                    .attr('transform', `translate(${x + left + translateX}, ${y + top + (this.height / 2)}) scale(${k})`);
            });

        const nodeUpdate = nodeEnter.merge(node);

        nodeUpdate
            .transition()
            .ease(easeSinInOut)
            .duration(this.duration)
            .attr('transform', d => `translate(${d.y}, ${d.x})`);

        nodeUpdate
            .select('circle.node')
            .attr('r', 6)
            .style('fill', this.topicColors)
            .style('stroke', d => (d.childrens ? '#039be5' : '#fff'));

        nodeUpdate
            .selectAll('text')
            .transition()
            .ease(easeSinInOut)
            .style('fill-opacity', 1);

        const nodeExit = node
            .exit()
            .transition()
            .ease(easeSinInOut)
            .duration(this.duration)
            .attr('transform', `translate(${source.y}, ${source.x})`)
            .remove();

        nodeExit
            .select('circle')
            .attr('r', 0);

        nodeExit
            .select('text')
            .style('fill-opacity', 0);
    }

    addLinks = (group, source, links) => {
        const link = group
            .selectAll('path.link')
            .data(links, d => d.id);

        const linkEnter = link
            .enter()
            .insert('path', 'g')
            .attr('class', 'link')
            .attr('stroke', this.topicColors)
            .attr('stroke-width', '1px')
            .attr('fill', 'none')
            .attr('d', () => {
                const out = { x: source.x0, y: source.y0 };
                return this.diagonal(out, out);
            });

        const linkUpdate = linkEnter
            .merge(link);

        linkUpdate
            .transition()
            .ease(easeSinInOut)
            .duration(this.duration)
            .attr('d', d => this.diagonal(d, d.parent));

        link
            .exit()
            .transition()
            .ease(easeSinInOut)
            .duration(this.duration)
            .attr('d', () => {
                const out = { x: source.x, y: source.y };
                return this.diagonal(out, out);
            })
            .remove();
    }

    update = (source) => {
        const treeData = this.trees(this.root);
        const nodes = treeData.descendants();
        const links = treeData.descendants().slice(1);

        this.addNodes(this.group, source, nodes);
        this.addLinks(this.group, source, links);
        nodes.forEach((d) => {
            d.x0 = d.x; // eslint-disable-line no-param-reassign
            d.y0 = d.y; // eslint-disable-line no-param-reassign
        });
    }

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

        if (!boundingClientRect.width) {
            return;
        }

        if (boundingClientRect.width === 0 || boundingClientRect.height === 0) {
            return;
        }

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

        this.setupChart();
        this.update(this.root);
    }

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

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

        const treeStyle = [
            'collapsible-tree',
            className,
        ].join(' ');

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

export default Responsive(CollapsibleTree);