388 lines
11 KiB
JavaScript
388 lines
11 KiB
JavaScript
/** @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 (
|
|
<SelectControl
|
|
className="woocommerce-chart__interval-select"
|
|
value={ interval }
|
|
options={ allowedIntervals.map( allowedInterval => ( {
|
|
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,
|
|
interval,
|
|
valueType,
|
|
} = this.props;
|
|
let { yFormat } = this.props;
|
|
const legendDirection = layout === 'standard' && width >= WIDE_BREAKPOINT ? 'row' : 'column';
|
|
const chartDirection = layout === 'comparison' && width >= WIDE_BREAKPOINT ? 'row' : 'column';
|
|
const legend = (
|
|
<Legend
|
|
className={ 'woocommerce-chart__legend' }
|
|
colorScheme={ d3InterpolateViridis }
|
|
data={ orderedKeys }
|
|
handleLegendHover={ this.handleLegendHover }
|
|
handleLegendToggle={ this.handleLegendToggle }
|
|
legendDirection={ legendDirection }
|
|
valueType={ valueType }
|
|
/>
|
|
);
|
|
const margin = {
|
|
bottom: 50,
|
|
left: 80,
|
|
right: 30,
|
|
top: 0,
|
|
};
|
|
|
|
switch ( valueType ) {
|
|
case 'average':
|
|
yFormat = '.0f';
|
|
break;
|
|
case 'currency':
|
|
yFormat = '$.3s';
|
|
break;
|
|
case 'number':
|
|
yFormat = '.0f';
|
|
break;
|
|
}
|
|
return (
|
|
<div className="woocommerce-chart" ref={ this.chartRef }>
|
|
<div className="woocommerce-chart__header">
|
|
<H className="woocommerce-chart__title">{ title }</H>
|
|
{ width >= WIDE_BREAKPOINT && legendDirection === 'row' && legend }
|
|
{ this.renderIntervalSelector() }
|
|
<NavigableMenu
|
|
className="woocommerce-chart__types"
|
|
orientation="horizontal"
|
|
role="menubar"
|
|
>
|
|
<IconButton
|
|
className={ classNames( 'woocommerce-chart__type-button', {
|
|
'woocommerce-chart__type-button-selected': type === 'line',
|
|
} ) }
|
|
icon={ <Gridicon icon="line-graph" /> }
|
|
title={ __( 'Line chart', 'wc-admin' ) }
|
|
aria-checked={ type === 'line' }
|
|
role="menuitemradio"
|
|
tabIndex={ type === 'line' ? 0 : -1 }
|
|
onClick={ partial( this.handleTypeToggle, 'line' ) }
|
|
/>
|
|
<IconButton
|
|
className={ classNames( 'woocommerce-chart__type-button', {
|
|
'woocommerce-chart__type-button-selected': type === 'bar',
|
|
} ) }
|
|
icon={ <Gridicon icon="stats-alt" /> }
|
|
title={ __( 'Bar chart', 'wc-admin' ) }
|
|
aria-checked={ type === 'bar' }
|
|
role="menuitemradio"
|
|
tabIndex={ type === 'bar' ? 0 : -1 }
|
|
onClick={ partial( this.handleTypeToggle, 'bar' ) }
|
|
/>
|
|
</NavigableMenu>
|
|
</div>
|
|
<Section component={ false }>
|
|
<div
|
|
className={ classNames(
|
|
'woocommerce-chart__body',
|
|
`woocommerce-chart__body-${ chartDirection }`
|
|
) }
|
|
>
|
|
{ width >= WIDE_BREAKPOINT && legendDirection === 'column' && legend }
|
|
<D3Chart
|
|
colorScheme={ d3InterpolateViridis }
|
|
data={ visibleData }
|
|
dateParser={ dateParser }
|
|
height={ 300 }
|
|
margin={ margin }
|
|
mode={ mode }
|
|
orderedKeys={ orderedKeys }
|
|
pointLabelFormat={ pointLabelFormat }
|
|
tooltipFormat={ tooltipFormat }
|
|
tooltipTitle={ tooltipTitle }
|
|
type={ type }
|
|
interval={ interval }
|
|
width={ chartDirection === 'row' ? width - 320 : width }
|
|
xFormat={ xFormat }
|
|
x2Format={ x2Format }
|
|
yFormat={ yFormat }
|
|
valueType={ valueType }
|
|
/>
|
|
</div>
|
|
{ width < WIDE_BREAKPOINT && <div className="woocommerce-chart__footer">{ legend }</div> }
|
|
</Section>
|
|
</div>
|
|
);
|
|
}
|
|
}
|
|
|
|
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,
|
|
/**
|
|
* What type of data is to be displayed? Number, Average, String?
|
|
*/
|
|
valueType: PropTypes.string,
|
|
};
|
|
|
|
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;
|