toggle-corp/react-store

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

Summary

Maintainability
A
0 mins
Test Coverage
import React, { PureComponent } from 'react';
import PropTypes from 'prop-types';
import cloud from 'd3-cloud';
import SvgSaver from 'svgsaver';
import { select } from 'd3-selection';
import { scaleOrdinal } from 'd3-scale';
import { schemeSet2 } from 'd3-scale-chromatic';
import { _cs } from '@togglecorp/fujs';
import memoize from 'memoize-one';

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

import styles from './styles.scss';

const propTypes = {
    className: PropTypes.string,
    /**
     * Size of the parent element/component (passed by the Responsive hoc)
     */
    boundingClientRect: PropTypes.shape({
        width: PropTypes.number,
        height: PropTypes.number,
    }).isRequired,
    /**
     * Data to be represented in the word cloud.
     */
    data: PropTypes.arrayOf(PropTypes.object).isRequired,
    /**
     * Select a label for each data point
     */
    // eslint-disable-next-line react/no-unused-prop-types
    labelSelector: PropTypes.func,
    /**
     * Select the frequency value for each data point
     */
    // eslint-disable-next-line react/no-unused-prop-types
    frequencySelector: PropTypes.func,
    /**
     * Font specification for each word cloud node
     */
    // eslint-disable-next-line react/no-unused-prop-types
    font: PropTypes.oneOfType([PropTypes.string, PropTypes.func]),
    /**
     * Provide a rotation value for each node
     */
    // eslint-disable-next-line react/no-unused-prop-types
    rotate: PropTypes.oneOfType([PropTypes.number, PropTypes.func]),
    /**
     * Handler function to save the generated svg
     */
    setSaveFunction: PropTypes.func,
    // onWordMouseOver: PropTypes.func,
    // onWordClick: PropTypes.func,
    /**
     * Array of colors as hex color codes
     */
    colorScheme: PropTypes.arrayOf(PropTypes.string),
};

const defaultProps = {
    className: '',
    font: 'sans-serif',
    // rotate: () => (Math.floor(Math.random() * 2) * 90),
    rotate: 0,
    setSaveFunction: undefined,
    // onWordClick: undefined,
    // onWordMouseOver: undefined,
    labelSelector: d => d.text,
    frequencySelector: d => d.size,
    colorScheme: schemeSet2,
};

const emptyObject = {};
const emptyList = [];

/**
 * Display how frequently a word appears by making the size of each word proportion to its
 * frequency.
 */
class WordCloud extends PureComponent {
    static propTypes = propTypes;

    static defaultProps = defaultProps;

    constructor(props) {
        super(props);

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

        this.state = {
            calculatedWords: emptyList,
        };

        this.startWordCloudComputation(props);
    }

    // eslint-disable-next-line camelcase
    UNSAFE_componentWillReceiveProps(nextProps) {
        this.startWordCloudComputation(nextProps);
    }

    getColorScale = colorScheme => scaleOrdinal().range(colorScheme);

    startWordCloudComputation = ({
        boundingClientRect: {
            width: containerWidth,
            height: containerHeight,
        } = emptyObject,
        data,
        font,
        rotate,
        labelSelector,
        frequencySelector,
    }) => {
        const isContainerInvalid = !containerWidth;
        const isDataInvalid = !data || data.length === 0;

        if (isContainerInvalid || isDataInvalid) {
            return;
        }

        this.calculateWordCloud(
            containerWidth,
            containerHeight,
            font,
            data,
            rotate,
            labelSelector,
            frequencySelector,
        );
    }

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

    handleWordCloudCalculationEnd = (words) => {
        this.setState({ calculatedWords: words });
    }

    // eslint-disable-next-line react/sort-comp
    calculateWordCloud = memoize((
        width,
        height,
        font,
        data,
        rotate,
        labelSelector,
        frequencySelector,
    ) => {
        this.setState({ calculatedWords: undefined });

        const words = data.map(labelSelector);
        const frequencies = data.map(frequencySelector);

        const renderArea = width * height;
        const totalLetterLength = words.join(' ').length;
        const renderAreaFactor = renderArea / totalLetterLength;

        const minFont = 7;
        const maxFont = Math.max(
            2 * minFont,
            Math.sqrt(renderAreaFactor),
        );

        /*
        const paddingFactor = 5;
        const padding = paddingFactor > 0
            ? Math.sqrt(renderAreaFactor) / paddingFactor
            : 0;
        */

        const maxSize = Math.max(...frequencies, 1);
        const sizeOffset = maxFont / maxSize;
        const fontSizeSelector = d => Math.max(
            minFont,
            Math.min(frequencySelector(d) * sizeOffset, maxFont),
        );

        const layoutData = JSON.parse(JSON.stringify(data));

        const layout = cloud();
        layout.size([width, height])
            .font(font)
            .words(layoutData)
            .padding(2)
            .rotate(rotate)
            .text(labelSelector)
            .fontSize(fontSizeSelector)
            .on('end', this.handleWordCloudCalculationEnd);

        setTimeout(layout.start, 0);
    })

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

        const isContainerInvalid = !containerWidth;
        const isDataInvalid = !data || data.length === 0;

        if (isContainerInvalid || isDataInvalid) {
            return null;
        }

        const className = _cs(
            classNameFromProps,
            styles.wordCloud,
            'word-cloud',
        );

        const colorScale = this.getColorScale(colorScheme);
        const { calculatedWords } = this.state;

        return (
            <div
                className={className}
                style={{
                    width: containerWidth,
                    height: containerHeight,
                }}
            >
                { calculatedWords && (
                    <svg
                        // className={svgClassName}
                        width={containerWidth}
                        height={containerHeight}
                        ref={this.svgRef}
                    >
                        <g transform={`translate(${containerWidth / 2}, ${containerHeight / 2})`}>
                            { calculatedWords.map(w => (
                                <text
                                    key={w.text}
                                    style={{
                                        fontSize: w.size,
                                        fontFamily: w.font,
                                    }}
                                    fill={colorScale(w.text)}
                                    textAnchor="middle"
                                    transform={`translate(${w.x}, ${w.y}) rotate(${w.rotate})`}
                                >
                                    { w.text }
                                </text>
                            )) }
                        </g>
                    </svg>
                ) }
            </div>
        );
    }
}

export default Responsive(WordCloud);