/** @format */ /** * External dependencies */ import { decodeEntities } from '@wordpress/html-entities'; import { __ } from '@wordpress/i18n'; import classNames from 'classnames'; import { get, isEqual, partial } from 'lodash'; import { Component, createRef } from '@wordpress/element'; import { IconButton, NavigableMenu, SelectControl } from '@wordpress/components'; import PropTypes from 'prop-types'; import { interpolateViridis as d3InterpolateViridis } from 'd3-scale-chromatic'; import { formatDefaultLocale as d3FormatDefaultLocale } from 'd3-format'; import Gridicon from 'gridicons'; /** * Internal dependencies */ import D3Chart from './charts'; import Legend from './legend'; import { H, Section } from 'components/section'; import { gap, gaplarge } from 'stylesheets/abstracts/_variables.scss'; import { updateQueryString } from 'lib/nav-utils'; const WIDE_BREAKPOINT = 1100; d3FormatDefaultLocale( { decimal: '.', thousands: ',', grouping: [ 3 ], currency: [ decodeEntities( get( wcSettings, 'currency.symbol', '' ) ), '' ], } ); function getOrderedKeys( props ) { const updatedKeys = [ ...new Set( props.data.reduce( ( accum, curr ) => { Object.keys( curr ).forEach( key => key !== 'date' && accum.push( key ) ); return accum; }, [] ) ), ].map( key => ( { key, total: props.data.reduce( ( a, c ) => a + c[ key ].value, 0 ), visible: true, focus: true, } ) ); if ( props.layout === 'comparison' ) { updatedKeys.sort( ( a, b ) => b.total - a.total ); } return updatedKeys; } /** * A chart container using d3, to display timeseries data with an interactive legend. */ class Chart extends Component { constructor( props ) { super( props ); this.chartRef = createRef(); const wpBody = document.getElementById( 'wpbody' ).getBoundingClientRect().width; const wpWrap = document.getElementById( 'wpwrap' ).getBoundingClientRect().width; const calcGap = wpWrap > 782 ? gaplarge.match( /\d+/ )[ 0 ] : gap.match( /\d+/ )[ 0 ]; this.state = { data: props.data, orderedKeys: getOrderedKeys( props ), type: props.type, visibleData: [ ...props.data ], width: wpBody - 2 * calcGap, }; this.handleTypeToggle = this.handleTypeToggle.bind( this ); this.handleLegendToggle = this.handleLegendToggle.bind( this ); this.handleLegendHover = this.handleLegendHover.bind( this ); this.updateDimensions = this.updateDimensions.bind( this ); this.getVisibleData = this.getVisibleData.bind( this ); } componentDidUpdate( prevProps ) { const { data } = this.props; const orderedKeys = getOrderedKeys( this.props ); if ( ! isEqual( [ ...data ].sort(), [ ...prevProps.data ].sort() ) ) { /* eslint-disable react/no-did-update-set-state */ this.setState( { orderedKeys: orderedKeys, visibleData: this.getVisibleData( data, orderedKeys ), } ); /* eslint-enable react/no-did-update-set-state */ } } componentDidMount() { window.addEventListener( 'resize', this.updateDimensions ); } componentWillUnmount() { window.removeEventListener( 'resize', this.updateDimensions ); } handleTypeToggle( type ) { if ( this.state.type !== type ) { this.setState( { type } ); } } handleLegendToggle( event ) { const { data } = this.props; const orderedKeys = this.state.orderedKeys.map( d => ( { ...d, visible: d.key === event.target.id ? ! d.visible : d.visible, } ) ); const copyEvent = { ...event }; // can't pass a synthetic event into the hover handler this.setState( { orderedKeys, visibleData: this.getVisibleData( data, orderedKeys ), }, () => { this.handleLegendHover( copyEvent ); } ); } handleLegendHover( event ) { const hoverTarget = this.state.orderedKeys.filter( d => d.key === event.target.id )[ 0 ]; this.setState( { orderedKeys: this.state.orderedKeys.map( d => { let enterFocus = d.key === event.target.id ? true : false; enterFocus = ! hoverTarget.visible ? true : enterFocus; return { ...d, focus: event.type === 'mouseleave' || event.type === 'blur' ? true : enterFocus, }; } ), } ); } updateDimensions() { this.setState( { width: this.chartRef.current.offsetWidth, } ); } getVisibleData( data, orderedKeys ) { const visibleKeys = orderedKeys.filter( d => d.visible ); return data.map( d => { const newRow = { date: d.date }; visibleKeys.forEach( row => { newRow[ row.key ] = d[ row.key ]; } ); return newRow; } ); } setInterval( interval ) { updateQueryString( { interval } ); } renderIntervalSelector() { const { interval, allowedIntervals } = this.props; if ( ! allowedIntervals || allowedIntervals.length < 1 ) { return null; } const intervalLabels = { hour: __( 'By hour', 'wc-admin' ), day: __( 'By day', 'wc-admin' ), week: __( 'By week', 'wc-admin' ), month: __( 'By month', 'wc-admin' ), quarter: __( 'By quarter', 'wc-admin' ), year: __( 'By year', 'wc-admin' ), }; return ( ( { value: allowedInterval, label: intervalLabels[ allowedInterval ], } ) ) } onChange={ this.setInterval } /> ); } render() { const { orderedKeys, type, visibleData, width } = this.state; const { dateParser, layout, mode, pointLabelFormat, title, tooltipFormat, tooltipTitle, xFormat, x2Format, yFormat, interval, } = this.props; const legendDirection = layout === 'standard' && width >= WIDE_BREAKPOINT ? 'row' : 'column'; const chartDirection = layout === 'comparison' && width >= WIDE_BREAKPOINT ? 'row' : 'column'; const legend = ( ); const margin = { bottom: 50, left: 80, right: 30, top: 0, }; return (
{ title } { width >= WIDE_BREAKPOINT && legendDirection === 'row' && legend } { this.renderIntervalSelector() } } title={ __( 'Line chart', 'wc-admin' ) } aria-checked={ type === 'line' } role="menuitemradio" tabIndex={ type === 'line' ? 0 : -1 } onClick={ partial( this.handleTypeToggle, 'line' ) } /> } title={ __( 'Bar chart', 'wc-admin' ) } aria-checked={ type === 'bar' } role="menuitemradio" tabIndex={ type === 'bar' ? 0 : -1 } onClick={ partial( this.handleTypeToggle, 'bar' ) } />
{ width >= WIDE_BREAKPOINT && legendDirection === 'column' && legend }
{ width < WIDE_BREAKPOINT &&
{ legend }
}
); } } Chart.propTypes = { /** * An array of data. */ data: PropTypes.array.isRequired, /** * Format to parse dates into d3 time format */ dateParser: PropTypes.string.isRequired, /** * Date format of the point labels (might be used in tooltips and ARIA properties). */ pointLabelFormat: PropTypes.string, /** * A datetime formatting string to format the date displayed as the title of the toolip * if `tooltipTitle` is missing, passed to d3TimeFormat. */ tooltipFormat: PropTypes.string, /** * A string to use as a title for the tooltip. Takes preference over `tooltipFormat`. */ tooltipTitle: PropTypes.string, /** * A datetime formatting string, passed to d3TimeFormat. */ xFormat: PropTypes.string, /** * A datetime formatting string, passed to d3TimeFormat. */ x2Format: PropTypes.string, /** * A number formatting string, passed to d3Format. */ yFormat: PropTypes.string, /** * `standard` (default) legend layout in the header or `comparison` moves legend layout * to the left or 'compact' has the legend below */ layout: PropTypes.oneOf( [ 'standard', 'comparison', 'compact' ] ), /** * `item-comparison` (default) or `time-comparison`, this is used to generate correct * ARIA properties. */ mode: PropTypes.oneOf( [ 'item-comparison', 'time-comparison' ] ), /** * A title describing this chart. */ title: PropTypes.string, /** * Chart type of either `line` or `bar`. */ type: PropTypes.oneOf( [ 'bar', 'line' ] ), /** * Information about the currently selected interval, and set of allowed intervals for the chart. See `getIntervalsForQuery`. */ intervalData: PropTypes.object, /** * Interval specification (hourly, daily, weekly etc). */ interval: PropTypes.oneOf( [ 'hour', 'day', 'week', 'month', 'quarter', 'year' ] ), /** * Allowed intervals to show in a dropdown. */ allowedIntervals: PropTypes.array, }; Chart.defaultProps = { data: [], dateParser: '%Y-%m-%dT%H:%M:%S', tooltipFormat: '%B %d, %Y', xFormat: '%d', x2Format: '%b %Y', yFormat: '$.3s', layout: 'standard', mode: 'item-comparison', type: 'line', interval: 'day', }; export default Chart;