diff --git a/plugins/woocommerce-admin/packages/components/src/chart/d3chart/chart.js b/plugins/woocommerce-admin/packages/components/src/chart/d3chart/chart.js index b7144a65a43..14e26e91fce 100644 --- a/plugins/woocommerce-admin/packages/components/src/chart/d3chart/chart.js +++ b/plugins/woocommerce-admin/packages/components/src/chart/d3chart/chart.js @@ -15,24 +15,25 @@ import { select as d3Select } from 'd3-selection'; */ import D3Base from './d3base'; import { - drawAxis, - drawBars, - drawLines, getDateSpaces, getOrderedKeys, getLine, getLineData, - getXTicks, getUniqueKeys, getUniqueDates, + getFormatter, +} from './utils'; +import { getXScale, getXGroupScale, getXLineScale, getYMax, getYScale, getYTickOffset, - getFormatter, -} from './utils'; +} from './utils/scales'; +import { drawAxis, getXTicks } from './utils/axis'; +import { drawBars } from './utils/bar-chart'; +import { drawLines } from './utils/line-chart'; /** * A simple D3 line and bar chart component for timeseries data in React. diff --git a/plugins/woocommerce-admin/packages/components/src/chart/d3chart/legend.js b/plugins/woocommerce-admin/packages/components/src/chart/d3chart/legend.js index 9ba9c053354..48684fa0604 100644 --- a/plugins/woocommerce-admin/packages/components/src/chart/d3chart/legend.js +++ b/plugins/woocommerce-admin/packages/components/src/chart/d3chart/legend.js @@ -9,7 +9,8 @@ import PropTypes from 'prop-types'; /** * Internal dependencies */ -import { getColor, getFormatter } from './utils'; +import { getFormatter } from './utils'; +import { getColor } from './utils/color'; /** * A legend specifically designed for the WooCommerce admin charts. diff --git a/plugins/woocommerce-admin/packages/components/src/chart/d3chart/test/fixtures/dummy-hour.js b/plugins/woocommerce-admin/packages/components/src/chart/d3chart/test/fixtures/dummy-hour.js deleted file mode 100644 index b310f491f07..00000000000 --- a/plugins/woocommerce-admin/packages/components/src/chart/d3chart/test/fixtures/dummy-hour.js +++ /dev/null @@ -1,25 +0,0 @@ -/** @format */ - -// /** -// * /* eslint-disable quote-props -// * -// * @format -// */ - -export default [ - { - date: '2018-08-01T00:00:00', - 'Custom (Aug 1, 2018)': { label: '2018-08-01 00:00', value: 58929.99 }, - 'Previous Period (Jul 31, 2018)': { label: '2018-07-31 00:00', value: 160130.74000000002 }, - }, - { - date: '2018-08-01T01:00:00', - 'Custom (Aug 1, 2018)': { label: '2018-08-01 01:00', value: 3805.56 }, - 'Previous Period (Jul 31, 2018)': { label: '2018-07-31 01:00', value: 0 }, - }, - { - date: '2018-08-01T02:00:00', - 'Custom (Aug 1, 2018)': { label: '2018-08-01 02:00', value: 3805.56 }, - 'Previous Period (Jul 31, 2018)': { label: '2018-07-31 02:00', value: 0 }, - }, -]; diff --git a/plugins/woocommerce-admin/packages/components/src/chart/d3chart/utils.js b/plugins/woocommerce-admin/packages/components/src/chart/d3chart/utils.js deleted file mode 100644 index b8792ec9b65..00000000000 --- a/plugins/woocommerce-admin/packages/components/src/chart/d3chart/utils.js +++ /dev/null @@ -1,847 +0,0 @@ -/** @format */ - -/** - * External dependencies - */ -import { find, findIndex, get } from 'lodash'; -import { max as d3Max } from 'd3-array'; -import { axisBottom as d3AxisBottom, axisLeft as d3AxisLeft } from 'd3-axis'; -import { format as d3Format } from 'd3-format'; -import { - scaleBand as d3ScaleBand, - scaleLinear as d3ScaleLinear, - scaleTime as d3ScaleTime, -} from 'd3-scale'; -import { event as d3Event, select as d3Select } from 'd3-selection'; -import { line as d3Line } from 'd3-shape'; - -const dayTicksThreshold = 63; -const weekTicksThreshold = 9; -const smallBreak = 783; -const mediumBreak = 1130; -const wideBreak = 1365; -const smallPoints = 7; -const mediumPoints = 12; -const largePoints = 16; -const mostPoints = 31; - -/** - * Allows an overriding formatter or defaults to d3Format or d3TimeFormat - * @param {string|function} format - either a format string for the D3 formatters or an overriding fomatting method - * @param {function} formatter - default d3Format or another formatting method, which accepts the string `format` - * @returns {function} to be used to format an input given the format and formatter - */ -export const getFormatter = ( format, formatter = d3Format ) => - typeof format === 'function' ? format : formatter( format ); - -/** - * Describes `smallestFactor` - * @param {number} inputNum - any double or integer - * @returns {integer} smallest factor of num - */ -export const getFactors = inputNum => { - const numFactors = []; - for ( let i = 1; i <= Math.floor( Math.sqrt( inputNum ) ); i += 1 ) { - if ( inputNum % i === 0 ) { - numFactors.push( i ); - inputNum / i !== i && numFactors.push( inputNum / i ); - } - } - numFactors.sort( ( x, y ) => x - y ); // numeric sort - - return numFactors; -}; - -/** - * Describes `getUniqueKeys` - * @param {array} data - The chart component's `data` prop. - * @returns {array} of unique category keys - */ -export const getUniqueKeys = data => { - return [ - ...new Set( - data.reduce( ( accum, curr ) => { - Object.keys( curr ).forEach( key => key !== 'date' && accum.push( key ) ); - return accum; - }, [] ) - ), - ]; -}; - -/** - * Describes `getOrderedKeys` - * @param {array} data - The chart component's `data` prop. - * @param {array} uniqueKeys - from `getUniqueKeys`. - * @returns {array} of unique category keys ordered by cumulative total value - */ -export const getOrderedKeys = ( data, uniqueKeys ) => - uniqueKeys - .map( key => ( { - key, - focus: true, - total: data.reduce( ( a, c ) => a + c[ key ].value, 0 ), - visible: true, - } ) ) - .sort( ( a, b ) => b.total - a.total ); - -/** - * Describes `getLineData` - * @param {array} data - The chart component's `data` prop. - * @param {array} orderedKeys - from `getOrderedKeys`. - * @returns {array} an array objects with a category `key` and an array of `values` with `date` and `value` properties - */ -export const getLineData = ( data, orderedKeys ) => - orderedKeys.map( row => ( { - key: row.key, - focus: row.focus, - visible: row.visible, - values: data.map( d => ( { - date: d.date, - focus: row.focus, - label: get( d, [ row.key, 'label' ], '' ), - value: get( d, [ row.key, 'value' ], 0 ), - visible: row.visible, - } ) ), - } ) ); - -/** - * Describes `getUniqueDates` - * @param {array} lineData - from `GetLineData` - * @param {function} parseDate - D3 time format parser - * @returns {array} an array of unique date values sorted from earliest to latest - */ -export const getUniqueDates = ( lineData, parseDate ) => { - return [ - ...new Set( - lineData.reduce( ( accum, { values } ) => { - values.forEach( ( { date } ) => accum.push( date ) ); - return accum; - }, [] ) - ), - ].sort( ( a, b ) => parseDate( a ) - parseDate( b ) ); -}; - -export const getColor = ( key, params ) => { - const smallColorScales = [ - [], - [ 0.5 ], - [ 0.333, 0.667 ], - [ 0.2, 0.5, 0.8 ], - [ 0.12, 0.375, 0.625, 0.88 ], - ]; - let keyValue = 0; - const len = params.orderedKeys.length; - const idx = findIndex( params.orderedKeys, d => d.key === key ); - if ( len < 5 ) { - keyValue = smallColorScales[ len ][ idx ]; - } else { - keyValue = idx / ( params.orderedKeys.length - 1 ); - } - return params.colorScheme( keyValue ); -}; - -/** - * Describes getXScale - * @param {array} uniqueDates - from `getUniqueDates` - * @param {number} width - calculated width of the charting space - * @returns {function} a D3 scale of the dates - */ -export const getXScale = ( uniqueDates, width ) => - d3ScaleBand() - .domain( uniqueDates ) - .rangeRound( [ 0, width ] ) - .paddingInner( 0.1 ); - -/** - * Describes getXGroupScale - * @param {array} orderedKeys - from `getOrderedKeys` - * @param {function} xScale - from `getXScale` - * @returns {function} a D3 scale for each category within the xScale range - */ -export const getXGroupScale = ( orderedKeys, xScale ) => - d3ScaleBand() - .domain( orderedKeys.filter( d => d.visible ).map( d => d.key ) ) - .rangeRound( [ 0, xScale.bandwidth() ] ) - .padding( 0.07 ); - -/** - * Describes getXLineScale - * @param {array} uniqueDates - from `getUniqueDates` - * @param {number} width - calculated width of the charting space - * @returns {function} a D3 scaletime for each date - */ -export const getXLineScale = ( uniqueDates, width ) => - d3ScaleTime() - .domain( [ new Date( uniqueDates[ 0 ] ), new Date( uniqueDates[ uniqueDates.length - 1 ] ) ] ) - .rangeRound( [ 0, width ] ); - -/** - * Describes and rounds the maximum y value to the nearest thousand, ten-thousand, million etc. In case it is a decimal number, ceils it. - * @param {array} lineData - from `getLineData` - * @returns {number} the maximum value in the timeseries multiplied by 4/3 - */ -export const getYMax = lineData => { - const yMax = 4 / 3 * d3Max( lineData, d => d3Max( d.values.map( date => date.value ) ) ); - const pow3Y = Math.pow( 10, ( ( Math.log( yMax ) * Math.LOG10E + 1 ) | 0 ) - 2 ) * 3; - return Math.ceil( Math.ceil( yMax / pow3Y ) * pow3Y ); -}; - -/** - * Describes getYScale - * @param {number} height - calculated height of the charting space - * @param {number} yMax - from `getYMax` - * @returns {function} the D3 linear scale from 0 to the value from `getYMax` - */ -export const getYScale = ( height, yMax ) => - d3ScaleLinear() - .domain( [ 0, yMax ] ) - .rangeRound( [ height, 0 ] ); - -/** - * Describes getyTickOffset - * @param {number} height - calculated height of the charting space - * @param {number} yMax - from `getYMax` - * @returns {function} the D3 linear scale from 0 to the value from `getYMax`, offset by 12 pixels down - */ -export const getYTickOffset = ( height, yMax ) => - d3ScaleLinear() - .domain( [ 0, yMax ] ) - .rangeRound( [ height + 12, 12 ] ); - -/** - * Describes getyTickOffset - * @param {function} xLineScale - from `getXLineScale`. - * @param {function} yScale - from `getYScale`. - * @returns {function} the D3 line function for plotting all category values - */ -export const getLine = ( xLineScale, yScale ) => - d3Line() - .x( d => xLineScale( new Date( d.date ) ) ) - .y( d => yScale( d.value ) ); - -/** - * Calculate the maximum number of ticks allowed in the x-axis based on the width and mode of the chart - * @param {integer} width - calculated page width - * @param {string} mode - item-comparison or time-comparison - * @returns {integer} number of x-axis ticks based on width and chart mode - */ -export const calculateMaxXTicks = ( width, mode ) => { - if ( width < smallBreak ) { - return smallPoints; - } else if ( width >= smallBreak && width <= mediumBreak ) { - return mediumPoints; - } else if ( width > mediumBreak && width <= wideBreak ) { - if ( mode === 'time-comparison' ) { - return largePoints; - } else if ( mode === 'item-comparison' ) { - return mediumPoints; - } - } else if ( width > wideBreak ) { - if ( mode === 'time-comparison' ) { - return mostPoints; - } else if ( mode === 'item-comparison' ) { - return largePoints; - } - } - - return largePoints; -}; - -/** - * Filter out irrelevant dates so only the first date of each month is kept. - * @param {array} dates - string dates. - * @returns {array} Filtered dates. - */ -export const getFirstDatePerMonth = dates => { - return dates.filter( - ( date, i ) => i === 0 || new Date( date ).getMonth() !== new Date( dates[ i - 1 ] ).getMonth() - ); -}; - -/** - * Get x-axis ticks given the unique dates and the increment factor. - * @param {array} uniqueDates - all the unique dates from the input data for the chart - * @param {integer} incrementFactor - increment factor for the visible ticks. - * @returns {array} Ticks for the x-axis. - */ -export const getXTicksFromIncrementFactor = ( uniqueDates, incrementFactor ) => { - const ticks = []; - - for ( let idx = 0; idx < uniqueDates.length; idx = idx + incrementFactor ) { - ticks.push( uniqueDates[ idx ] ); - } - - // If the first date is missing from the ticks array, add it back in. - if ( ticks[ 0 ] !== uniqueDates[ 0 ] ) { - ticks.unshift( uniqueDates[ 0 ] ); - } - - return ticks; -}; - -/** - * Calculates the increment factor between ticks so there aren't more than maxTicks. - * @param {array} uniqueDates - all the unique dates from the input data for the chart - * @param {integer} maxTicks - maximum number of ticks that can be displayed in the x-axis - * @returns {integer} x-axis ticks increment factor - */ -export const calculateXTicksIncrementFactor = ( uniqueDates, maxTicks ) => { - let factors = []; - let i = 1; - // First we get all the factors of the length of the uniqueDates array - // if the number is a prime number or near prime (with 3 factors) then we - // step down by 1 integer and try again. - while ( factors.length <= 3 ) { - factors = getFactors( uniqueDates.length - i ); - i += 1; - } - - return factors.find( f => uniqueDates.length / f < maxTicks ); -}; - -/** - * Given an array of dates, returns true if the first and last one belong to the same day. - * @param {array} dates - an array of dates - * @returns {boolean} whether the first and last date are different hours from the same date. - */ -const areDatesInTheSameDay = dates => { - const firstDate = new Date( dates [ 0 ] ); - const lastDate = new Date( dates [ dates.length - 1 ] ); - return ( - firstDate.getDate() === lastDate.getDate() && - firstDate.getMonth() === lastDate.getMonth() && - firstDate.getFullYear() === lastDate.getFullYear() - ); -}; - -/** - * Returns ticks for the x-axis. - * @param {array} uniqueDates - all the unique dates from the input data for the chart - * @param {integer} width - calculated page width - * @param {string} mode - item-comparison or time-comparison - * @param {string} interval - string of the interval used in the graph (hour, day, week...) - * @returns {integer} number of x-axis ticks based on width and chart mode - */ -export const getXTicks = ( uniqueDates, width, mode, interval ) => { - const maxTicks = calculateMaxXTicks( width, mode ); - - if ( - ( uniqueDates.length >= dayTicksThreshold && interval === 'day' ) || - ( uniqueDates.length >= weekTicksThreshold && interval === 'week' ) - ) { - uniqueDates = getFirstDatePerMonth( uniqueDates ); - } - if ( uniqueDates.length <= maxTicks || - ( interval === 'hour' && areDatesInTheSameDay( uniqueDates ) && width > smallBreak ) ) { - return uniqueDates; - } - - const incrementFactor = calculateXTicksIncrementFactor( uniqueDates, maxTicks ); - - return getXTicksFromIncrementFactor( uniqueDates, incrementFactor ); -}; - -/** - * Describes getDateSpaces - * @param {array} data - The chart component's `data` prop. - * @param {array} uniqueDates - from `getUniqueDates` - * @param {number} width - calculated width of the charting space - * @param {function} xLineScale - from `getXLineScale` - * @returns {array} that icnludes the date, start (x position) and width to mode the mouseover rectangles - */ -export const getDateSpaces = ( data, uniqueDates, width, xLineScale ) => - uniqueDates.map( ( d, i ) => { - const datapoints = find( data, { date: d } ); - const xNow = xLineScale( new Date( d ) ); - const xPrev = - i >= 1 - ? xLineScale( new Date( uniqueDates[ i - 1 ] ) ) - : xLineScale( new Date( uniqueDates[ 0 ] ) ); - const xNext = - i < uniqueDates.length - 1 - ? xLineScale( new Date( uniqueDates[ i + 1 ] ) ) - : xLineScale( new Date( uniqueDates[ uniqueDates.length - 1 ] ) ); - let xWidth = i === 0 ? xNext - xNow : xNow - xPrev; - const xStart = i === 0 ? 0 : xNow - xWidth / 2; - xWidth = i === 0 || i === uniqueDates.length - 1 ? xWidth / 2 : xWidth; - return { - date: d, - start: uniqueDates.length > 1 ? xStart : 0, - width: uniqueDates.length > 1 ? xWidth : width, - values: Object.keys( datapoints ) - .filter( key => key !== 'date' ) - .map( key => { - return { - key, - value: datapoints[ key ].value, - date: d, - }; - } ), - }; - } ); - -/** - * Compares 2 strings and returns a list of words that are unique from s2 - * @param {string} s1 - base string to compare against - * @param {string} s2 - string to compare against the base string - * @param {string|Object} splitChar - character or RegExp to use to deliminate words - * @returns {array} of unique words that appear in s2 but not in s1, the base string - */ -export const compareStrings = ( s1, s2, splitChar = new RegExp( [ ' |,' ], 'g' ) ) => { - const string1 = s1.split( splitChar ); - const string2 = s2.split( splitChar ); - const diff = new Array(); - const long = s1.length > s2.length ? string1 : string2; - for ( let x = 0; x < long.length; x++ ) { - string1[ x ] !== string2[ x ] && diff.push( string2[ x ] ); - } - return diff; -}; - -export const drawAxis = ( node, params ) => { - const xScale = params.type === 'line' ? params.xLineScale : params.xScale; - const removeDuplicateDates = ( d, i, ticks, formatter ) => { - const monthDate = d instanceof Date ? d : new Date( d ); - let prevMonth = i !== 0 ? ticks[ i - 1 ] : ticks[ i ]; - prevMonth = prevMonth instanceof Date ? prevMonth : new Date( prevMonth ); - return i === 0 - ? formatter( monthDate ) - : compareStrings( formatter( prevMonth ), formatter( monthDate ) ).join( ' ' ); - }; - - const yGrids = []; - for ( let i = 0; i < 4; i++ ) { - if ( params.yMax > 1 ) { - const roundedValue = Math.round( i / 3 * params.yMax ); - if ( yGrids[ yGrids.length - 1 ] !== roundedValue ) { - yGrids.push( roundedValue ); - } - } else { - yGrids.push( i / 3 * params.yMax ); - } - } - - const ticks = params.xTicks.map( d => ( params.type === 'line' ? new Date( d ) : d ) ); - - node - .append( 'g' ) - .attr( 'class', 'axis' ) - .attr( 'aria-hidden', 'true' ) - .attr( 'transform', `translate(0, ${ params.height })` ) - .call( - d3AxisBottom( xScale ) - .tickValues( ticks ) - .tickFormat( ( d, i ) => params.interval === 'hour' - ? params.xFormat( d ) - : removeDuplicateDates( d, i, ticks, params.xFormat ) ) - ); - - node - .append( 'g' ) - .attr( 'class', 'axis axis-month' ) - .attr( 'aria-hidden', 'true' ) - .attr( 'transform', `translate(0, ${ params.height + 20 })` ) - .call( - d3AxisBottom( xScale ) - .tickValues( ticks ) - .tickFormat( ( d, i ) => removeDuplicateDates( d, i, ticks, params.x2Format ) ) - ) - .call( g => g.select( '.domain' ).remove() ); - - node - .append( 'g' ) - .attr( 'class', 'pipes' ) - .attr( 'transform', `translate(0, ${ params.height })` ) - .call( - d3AxisBottom( xScale ) - .tickValues( ticks ) - .tickSize( 5 ) - .tickFormat( '' ) - ); - - node - .append( 'g' ) - .attr( 'class', 'grid' ) - .attr( 'transform', `translate(-${ params.margin.left },0)` ) - .call( - d3AxisLeft( params.yScale ) - .tickValues( yGrids ) - .tickSize( -params.width - params.margin.left - params.margin.right ) - .tickFormat( '' ) - ) - .call( g => g.select( '.domain' ).remove() ); - - node - .append( 'g' ) - .attr( 'class', 'axis y-axis' ) - .attr( 'aria-hidden', 'true' ) - .attr( 'transform', 'translate(-50, 0)' ) - .attr( 'text-anchor', 'start' ) - .call( - d3AxisLeft( params.yTickOffset ) - .tickValues( yGrids ) - .tickFormat( d => params.yFormat( d !== 0 ? d : 0 ) ) - ); - - node.selectAll( '.domain' ).remove(); - node - .selectAll( '.axis' ) - .selectAll( '.tick' ) - .select( 'line' ) - .remove(); -}; - -const getTooltipRowLabel = ( d, row, params ) => { - if ( d[ row.key ].labelDate ) { - return params.tooltipLabelFormat( - d[ row.key ].labelDate instanceof Date - ? d[ row.key ].labelDate - : new Date( d[ row.key ].labelDate ) - ); - } - return row.key; -}; - -const showTooltip = ( params, d, position ) => { - const keys = params.orderedKeys.filter( row => row.visible ).map( - row => ` -
  • -
    - - ${ getTooltipRowLabel( d, row, params ) } -
    - ${ params.tooltipValueFormat( d[ row.key ].value ) } -
  • - ` - ); - - const tooltipTitle = params.tooltipTitle - ? params.tooltipTitle - : params.tooltipLabelFormat( d.date instanceof Date ? d.date : new Date( d.date ) ); - - params.tooltip - .style( 'left', position.x + 'px' ) - .style( 'top', position.y + 'px' ) - .style( 'visibility', 'visible' ).html( ` -
    -

    ${ tooltipTitle }

    - -
    - ` ); -}; - -const handleMouseOverBarChart = ( date, parentNode, node, data, params, position ) => { - d3Select( parentNode ) - .select( '.barfocus' ) - .attr( 'opacity', '0.1' ); - showTooltip( params, data.find( e => e.date === date ), position ); -}; - -const handleMouseOutBarChart = ( parentNode, params ) => { - d3Select( parentNode ) - .select( '.barfocus' ) - .attr( 'opacity', '0' ); - params.tooltip.style( 'visibility', 'hidden' ); -}; - -const handleMouseOverLineChart = ( date, parentNode, node, data, params, position ) => { - d3Select( parentNode ) - .select( '.focus-grid' ) - .attr( 'opacity', '1' ); - showTooltip( params, data.find( e => e.date === date ), position ); -}; - -const handleMouseOutLineChart = ( parentNode, params ) => { - d3Select( parentNode ) - .select( '.focus-grid' ) - .attr( 'opacity', '0' ); - params.tooltip.style( 'visibility', 'hidden' ); -}; - -const calculateTooltipXPosition = ( - elementCoords, - chartCoords, - tooltipSize, - tooltipMargin, - elementWidthRatio, - tooltipPosition -) => { - const xPosition = - elementCoords.left + elementCoords.width * elementWidthRatio + tooltipMargin - chartCoords.left; - - if ( tooltipPosition === 'below' ) { - return Math.max( - tooltipMargin, - Math.min( - xPosition - tooltipSize.width / 2, - chartCoords.width - tooltipSize.width - tooltipMargin - ) - ); - } - - if ( xPosition + tooltipSize.width + tooltipMargin > chartCoords.width ) { - return Math.max( - tooltipMargin, - elementCoords.left + - elementCoords.width * ( 1 - elementWidthRatio ) - - tooltipSize.width - - tooltipMargin - - chartCoords.left - ); - } - - return xPosition; -}; - -const calculateTooltipYPosition = ( - elementCoords, - chartCoords, - tooltipSize, - tooltipMargin, - tooltipPosition -) => { - if ( tooltipPosition === 'below' ) { - return chartCoords.height; - } - - const yPosition = elementCoords.top + tooltipMargin - chartCoords.top; - if ( yPosition + tooltipSize.height + tooltipMargin > chartCoords.height ) { - return Math.max( 0, elementCoords.top - tooltipSize.height - tooltipMargin - chartCoords.top ); - } - - return yPosition; -}; - -const calculateTooltipPosition = ( element, chart, tooltipPosition, elementWidthRatio = 1 ) => { - const elementCoords = element.getBoundingClientRect(); - const chartCoords = chart.getBoundingClientRect(); - const tooltipSize = d3Select( '.d3-chart__tooltip' ) - .node() - .getBoundingClientRect(); - const tooltipMargin = 24; - - if ( tooltipPosition === 'below' ) { - elementWidthRatio = 0; - } - - return { - x: calculateTooltipXPosition( - elementCoords, - chartCoords, - tooltipSize, - tooltipMargin, - elementWidthRatio, - tooltipPosition - ), - y: calculateTooltipYPosition( - elementCoords, - chartCoords, - tooltipSize, - tooltipMargin, - tooltipPosition - ), - }; -}; - -export const drawLines = ( node, data, params ) => { - const series = node - .append( 'g' ) - .attr( 'class', 'lines' ) - .selectAll( '.line-g' ) - .data( params.lineData.filter( d => d.visible ).reverse() ) - .enter() - .append( 'g' ) - .attr( 'class', 'line-g' ) - .attr( 'role', 'region' ) - .attr( 'aria-label', d => d.key ); - - let lineStroke = params.width <= wideBreak || params.uniqueDates.length > 50 ? 2 : 3; - lineStroke = params.width <= smallBreak ? 1.25 : lineStroke; - const dotRadius = params.width <= wideBreak ? 4 : 6; - - series - .append( 'path' ) - .attr( 'fill', 'none' ) - .attr( 'stroke-width', lineStroke ) - .attr( 'stroke-linejoin', 'round' ) - .attr( 'stroke-linecap', 'round' ) - .attr( 'stroke', d => getColor( d.key, params ) ) - .style( 'opacity', d => { - const opacity = d.focus ? 1 : 0.1; - return d.visible ? opacity : 0; - } ) - .attr( 'd', d => params.line( d.values ) ); - - const minDataPointSpacing = 36; - - params.width / params.uniqueDates.length > minDataPointSpacing && - series - .selectAll( 'circle' ) - .data( ( d, i ) => d.values.map( row => ( { ...row, i, visible: d.visible, key: d.key } ) ) ) - .enter() - .append( 'circle' ) - .attr( 'r', dotRadius ) - .attr( 'fill', d => getColor( d.key, params ) ) - .attr( 'stroke', '#fff' ) - .attr( 'stroke-width', lineStroke + 1 ) - .style( 'opacity', d => { - const opacity = d.focus ? 1 : 0.1; - return d.visible ? opacity : 0; - } ) - .attr( 'cx', d => params.xLineScale( new Date( d.date ) ) ) - .attr( 'cy', d => params.yScale( d.value ) ) - .attr( 'tabindex', '0' ) - .attr( 'aria-label', d => { - const label = d.label - ? d.label - : params.tooltipLabelFormat( d.date instanceof Date ? d.date : new Date( d.date ) ); - return `${ label } ${ params.tooltipValueFormat( d.value ) }`; - } ) - .on( 'focus', ( d, i, nodes ) => { - const position = calculateTooltipPosition( - d3Event.target, - node.node(), - params.tooltipPosition - ); - handleMouseOverLineChart( d.date, nodes[ i ].parentNode, node, data, params, position ); - } ) - .on( 'blur', ( d, i, nodes ) => handleMouseOutLineChart( nodes[ i ].parentNode, params ) ); - - const focus = node - .append( 'g' ) - .attr( 'class', 'focusspaces' ) - .selectAll( '.focus' ) - .data( params.dateSpaces ) - .enter() - .append( 'g' ) - .attr( 'class', 'focus' ); - - const focusGrid = focus - .append( 'g' ) - .attr( 'class', 'focus-grid' ) - .attr( 'opacity', '0' ); - - focusGrid - .append( 'line' ) - .attr( 'x1', d => params.xLineScale( new Date( d.date ) ) ) - .attr( 'y1', 0 ) - .attr( 'x2', d => params.xLineScale( new Date( d.date ) ) ) - .attr( 'y2', params.height ); - - focusGrid - .selectAll( 'circle' ) - .data( d => d.values.reverse() ) - .enter() - .append( 'circle' ) - .attr( 'r', dotRadius + 2 ) - .attr( 'fill', d => getColor( d.key, params ) ) - .attr( 'stroke', '#fff' ) - .attr( 'stroke-width', lineStroke + 2 ) - .attr( 'cx', d => params.xLineScale( new Date( d.date ) ) ) - .attr( 'cy', d => params.yScale( d.value ) ); - - focus - .append( 'rect' ) - .attr( 'class', 'focus-g' ) - .attr( 'x', d => d.start ) - .attr( 'y', 0 ) - .attr( 'width', d => d.width ) - .attr( 'height', params.height ) - .attr( 'opacity', 0 ) - .on( 'mouseover', ( d, i, nodes ) => { - const elementWidthRatio = i === 0 || i === params.dateSpaces.length - 1 ? 0 : 0.5; - const position = calculateTooltipPosition( - d3Event.target, - node.node(), - params.tooltipPosition, - elementWidthRatio - ); - handleMouseOverLineChart( d.date, nodes[ i ].parentNode, node, data, params, position ); - } ) - .on( 'mouseout', ( d, i, nodes ) => handleMouseOutLineChart( nodes[ i ].parentNode, params ) ); -}; - -export const drawBars = ( node, data, params ) => { - const barGroup = node - .append( 'g' ) - .attr( 'class', 'bars' ) - .selectAll( 'g' ) - .data( data ) - .enter() - .append( 'g' ) - .attr( 'transform', d => `translate(${ params.xScale( d.date ) },0)` ) - .attr( 'class', 'bargroup' ) - .attr( 'role', 'region' ) - .attr( - 'aria-label', - d => - params.mode === 'item-comparison' - ? params.tooltipLabelFormat( d.date instanceof Date ? d.date : new Date( d.date ) ) - : null - ); - - barGroup - .append( 'rect' ) - .attr( 'class', 'barfocus' ) - .attr( 'x', 0 ) - .attr( 'y', 0 ) - .attr( 'width', params.xGroupScale.range()[ 1 ] ) - .attr( 'height', params.height ) - .attr( 'opacity', '0' ); - - barGroup - .selectAll( '.bar' ) - .data( d => - params.orderedKeys.filter( row => row.visible ).map( row => ( { - key: row.key, - focus: row.focus, - value: get( d, [ row.key, 'value' ], 0 ), - label: get( d, [ row.key, 'label' ], '' ), - visible: row.visible, - date: d.date, - } ) ) - ) - .enter() - .append( 'rect' ) - .attr( 'class', 'bar' ) - .attr( 'x', d => params.xGroupScale( d.key ) ) - .attr( 'y', d => params.yScale( d.value ) ) - .attr( 'width', params.xGroupScale.bandwidth() ) - .attr( 'height', d => params.height - params.yScale( d.value ) ) - .attr( 'fill', d => getColor( d.key, params ) ) - .attr( 'tabindex', '0' ) - .attr( 'aria-label', d => { - const label = params.mode === 'time-comparison' && d.label ? d.label : d.key; - return `${ label } ${ params.tooltipValueFormat( d.value ) }`; - } ) - .style( 'opacity', d => { - const opacity = d.focus ? 1 : 0.1; - return d.visible ? opacity : 0; - } ) - .on( 'focus', ( d, i, nodes ) => { - const targetNode = d.value > 0 ? d3Event.target : d3Event.target.parentNode; - const position = calculateTooltipPosition( targetNode, node.node(), params.tooltipPosition ); - handleMouseOverBarChart( d.date, nodes[ i ].parentNode, node, data, params, position ); - } ) - .on( 'blur', ( d, i, nodes ) => handleMouseOutBarChart( nodes[ i ].parentNode, params ) ); - - barGroup - .append( 'rect' ) - .attr( 'class', 'barmouse' ) - .attr( 'x', 0 ) - .attr( 'y', 0 ) - .attr( 'width', params.xGroupScale.range()[ 1 ] ) - .attr( 'height', params.height ) - .attr( 'opacity', '0' ) - .on( 'mouseover', ( d, i, nodes ) => { - const position = calculateTooltipPosition( - d3Event.target, - node.node(), - params.tooltipPosition - ); - handleMouseOverBarChart( d.date, nodes[ i ].parentNode, node, data, params, position ); - } ) - .on( 'mouseout', ( d, i, nodes ) => handleMouseOutBarChart( nodes[ i ].parentNode, params ) ); -}; diff --git a/plugins/woocommerce-admin/packages/components/src/chart/d3chart/utils/axis.js b/plugins/woocommerce-admin/packages/components/src/chart/d3chart/utils/axis.js new file mode 100644 index 00000000000..3062b90ac12 --- /dev/null +++ b/plugins/woocommerce-admin/packages/components/src/chart/d3chart/utils/axis.js @@ -0,0 +1,266 @@ +/** @format */ + +/** + * External dependencies + */ +import { axisBottom as d3AxisBottom, axisLeft as d3AxisLeft } from 'd3-axis'; +import { smallBreak, wideBreak } from './breakpoints'; + +const dayTicksThreshold = 63; +const weekTicksThreshold = 9; +const mediumBreak = 1130; +const smallPoints = 7; +const mediumPoints = 12; +const largePoints = 16; +const mostPoints = 31; + +/** +* Describes `smallestFactor` +* @param {number} inputNum - any double or integer +* @returns {integer} smallest factor of num +*/ +const getFactors = inputNum => { + const numFactors = []; + for ( let i = 1; i <= Math.floor( Math.sqrt( inputNum ) ); i += 1 ) { + if ( inputNum % i === 0 ) { + numFactors.push( i ); + inputNum / i !== i && numFactors.push( inputNum / i ); + } + } + numFactors.sort( ( x, y ) => x - y ); // numeric sort + + return numFactors; +}; + +/** + * Calculate the maximum number of ticks allowed in the x-axis based on the width and mode of the chart + * @param {integer} width - calculated page width + * @param {string} mode - item-comparison or time-comparison + * @returns {integer} number of x-axis ticks based on width and chart mode + */ +const calculateMaxXTicks = ( width, mode ) => { + if ( width < smallBreak ) { + return smallPoints; + } else if ( width >= smallBreak && width <= mediumBreak ) { + return mediumPoints; + } else if ( width > mediumBreak && width <= wideBreak ) { + if ( mode === 'time-comparison' ) { + return largePoints; + } else if ( mode === 'item-comparison' ) { + return mediumPoints; + } + } else if ( width > wideBreak ) { + if ( mode === 'time-comparison' ) { + return mostPoints; + } else if ( mode === 'item-comparison' ) { + return largePoints; + } + } + + return largePoints; +}; + +/** + * Get x-axis ticks given the unique dates and the increment factor. + * @param {array} uniqueDates - all the unique dates from the input data for the chart + * @param {integer} incrementFactor - increment factor for the visible ticks. + * @returns {array} Ticks for the x-axis. + */ +const getXTicksFromIncrementFactor = ( uniqueDates, incrementFactor ) => { + const ticks = []; + + for ( let idx = 0; idx < uniqueDates.length; idx = idx + incrementFactor ) { + ticks.push( uniqueDates[ idx ] ); + } + + // If the first date is missing from the ticks array, add it back in. + if ( ticks[ 0 ] !== uniqueDates[ 0 ] ) { + ticks.unshift( uniqueDates[ 0 ] ); + } + + return ticks; +}; + +/** + * Calculates the increment factor between ticks so there aren't more than maxTicks. + * @param {array} uniqueDates - all the unique dates from the input data for the chart + * @param {integer} maxTicks - maximum number of ticks that can be displayed in the x-axis + * @returns {integer} x-axis ticks increment factor + */ +const calculateXTicksIncrementFactor = ( uniqueDates, maxTicks ) => { + let factors = []; + let i = 1; + // First we get all the factors of the length of the uniqueDates array + // if the number is a prime number or near prime (with 3 factors) then we + // step down by 1 integer and try again. + while ( factors.length <= 3 ) { + factors = getFactors( uniqueDates.length - i ); + i += 1; + } + + return factors.find( f => uniqueDates.length / f < maxTicks ); +}; + +/** + * Given an array of dates, returns true if the first and last one belong to the same day. + * @param {array} dates - an array of dates + * @returns {boolean} whether the first and last date are different hours from the same date. + */ +const areDatesInTheSameDay = dates => { + const firstDate = new Date( dates [ 0 ] ); + const lastDate = new Date( dates [ dates.length - 1 ] ); + return ( + firstDate.getDate() === lastDate.getDate() && + firstDate.getMonth() === lastDate.getMonth() && + firstDate.getFullYear() === lastDate.getFullYear() + ); +}; + +/** +* Filter out irrelevant dates so only the first date of each month is kept. +* @param {array} dates - string dates. +* @returns {array} Filtered dates. +*/ +const getFirstDatePerMonth = dates => { + return dates.filter( + ( date, i ) => i === 0 || new Date( date ).getMonth() !== new Date( dates[ i - 1 ] ).getMonth() + ); +}; + +/** + * Returns ticks for the x-axis. + * @param {array} uniqueDates - all the unique dates from the input data for the chart + * @param {integer} width - calculated page width + * @param {string} mode - item-comparison or time-comparison + * @param {string} interval - string of the interval used in the graph (hour, day, week...) + * @returns {integer} number of x-axis ticks based on width and chart mode + */ +export const getXTicks = ( uniqueDates, width, mode, interval ) => { + const maxTicks = calculateMaxXTicks( width, mode ); + + if ( + ( uniqueDates.length >= dayTicksThreshold && interval === 'day' ) || + ( uniqueDates.length >= weekTicksThreshold && interval === 'week' ) + ) { + uniqueDates = getFirstDatePerMonth( uniqueDates ); + } + if ( uniqueDates.length <= maxTicks || + ( interval === 'hour' && areDatesInTheSameDay( uniqueDates ) && width > smallBreak ) ) { + return uniqueDates; + } + + const incrementFactor = calculateXTicksIncrementFactor( uniqueDates, maxTicks ); + + return getXTicksFromIncrementFactor( uniqueDates, incrementFactor ); +}; + +/** +* Compares 2 strings and returns a list of words that are unique from s2 +* @param {string} s1 - base string to compare against +* @param {string} s2 - string to compare against the base string +* @param {string|Object} splitChar - character or RegExp to use to deliminate words +* @returns {array} of unique words that appear in s2 but not in s1, the base string +*/ +export const compareStrings = ( s1, s2, splitChar = new RegExp( [ ' |,' ], 'g' ) ) => { + const string1 = s1.split( splitChar ); + const string2 = s2.split( splitChar ); + const diff = new Array(); + const long = s1.length > s2.length ? string1 : string2; + for ( let x = 0; x < long.length; x++ ) { + string1[ x ] !== string2[ x ] && diff.push( string2[ x ] ); + } + return diff; +}; + +export const drawAxis = ( node, params ) => { + const xScale = params.type === 'line' ? params.xLineScale : params.xScale; + const removeDuplicateDates = ( d, i, ticks, formatter ) => { + const monthDate = d instanceof Date ? d : new Date( d ); + let prevMonth = i !== 0 ? ticks[ i - 1 ] : ticks[ i ]; + prevMonth = prevMonth instanceof Date ? prevMonth : new Date( prevMonth ); + return i === 0 + ? formatter( monthDate ) + : compareStrings( formatter( prevMonth ), formatter( monthDate ) ).join( ' ' ); + }; + + const yGrids = []; + for ( let i = 0; i < 4; i++ ) { + if ( params.yMax > 1 ) { + const roundedValue = Math.round( i / 3 * params.yMax ); + if ( yGrids[ yGrids.length - 1 ] !== roundedValue ) { + yGrids.push( roundedValue ); + } + } else { + yGrids.push( i / 3 * params.yMax ); + } + } + + const ticks = params.xTicks.map( d => ( params.type === 'line' ? new Date( d ) : d ) ); + + node + .append( 'g' ) + .attr( 'class', 'axis' ) + .attr( 'aria-hidden', 'true' ) + .attr( 'transform', `translate(0, ${ params.height })` ) + .call( + d3AxisBottom( xScale ) + .tickValues( ticks ) + .tickFormat( ( d, i ) => params.interval === 'hour' + ? params.xFormat( d ) + : removeDuplicateDates( d, i, ticks, params.xFormat ) ) + ); + + node + .append( 'g' ) + .attr( 'class', 'axis axis-month' ) + .attr( 'aria-hidden', 'true' ) + .attr( 'transform', `translate(0, ${ params.height + 20 })` ) + .call( + d3AxisBottom( xScale ) + .tickValues( ticks ) + .tickFormat( ( d, i ) => removeDuplicateDates( d, i, ticks, params.x2Format ) ) + ) + .call( g => g.select( '.domain' ).remove() ); + + node + .append( 'g' ) + .attr( 'class', 'pipes' ) + .attr( 'transform', `translate(0, ${ params.height })` ) + .call( + d3AxisBottom( xScale ) + .tickValues( ticks ) + .tickSize( 5 ) + .tickFormat( '' ) + ); + + node + .append( 'g' ) + .attr( 'class', 'grid' ) + .attr( 'transform', `translate(-${ params.margin.left },0)` ) + .call( + d3AxisLeft( params.yScale ) + .tickValues( yGrids ) + .tickSize( -params.width - params.margin.left - params.margin.right ) + .tickFormat( '' ) + ) + .call( g => g.select( '.domain' ).remove() ); + + node + .append( 'g' ) + .attr( 'class', 'axis y-axis' ) + .attr( 'aria-hidden', 'true' ) + .attr( 'transform', 'translate(-50, 0)' ) + .attr( 'text-anchor', 'start' ) + .call( + d3AxisLeft( params.yTickOffset ) + .tickValues( yGrids ) + .tickFormat( d => params.yFormat( d !== 0 ? d : 0 ) ) + ); + + node.selectAll( '.domain' ).remove(); + node + .selectAll( '.axis' ) + .selectAll( '.tick' ) + .select( 'line' ) + .remove(); +}; diff --git a/plugins/woocommerce-admin/packages/components/src/chart/d3chart/utils/bar-chart.js b/plugins/woocommerce-admin/packages/components/src/chart/d3chart/utils/bar-chart.js new file mode 100644 index 00000000000..6a77f998743 --- /dev/null +++ b/plugins/woocommerce-admin/packages/components/src/chart/d3chart/utils/bar-chart.js @@ -0,0 +1,110 @@ +/** @format */ + +/** + * External dependencies + */ +import { get } from 'lodash'; +import { event as d3Event, select as d3Select } from 'd3-selection'; + +/** + * Internal dependencies + */ +import { getColor } from './color'; +import { calculateTooltipPosition, showTooltip } from './tooltip'; + +const handleMouseOverBarChart = ( date, parentNode, node, data, params, position ) => { + d3Select( parentNode ) + .select( '.barfocus' ) + .attr( 'opacity', '0.1' ); + showTooltip( params, data.find( e => e.date === date ), position ); +}; + +const handleMouseOutBarChart = ( parentNode, params ) => { + d3Select( parentNode ) + .select( '.barfocus' ) + .attr( 'opacity', '0' ); + params.tooltip.style( 'visibility', 'hidden' ); +}; + +export const drawBars = ( node, data, params ) => { + const barGroup = node + .append( 'g' ) + .attr( 'class', 'bars' ) + .selectAll( 'g' ) + .data( data ) + .enter() + .append( 'g' ) + .attr( 'transform', d => `translate(${ params.xScale( d.date ) },0)` ) + .attr( 'class', 'bargroup' ) + .attr( 'role', 'region' ) + .attr( + 'aria-label', + d => + params.mode === 'item-comparison' + ? params.tooltipLabelFormat( d.date instanceof Date ? d.date : new Date( d.date ) ) + : null + ); + + barGroup + .append( 'rect' ) + .attr( 'class', 'barfocus' ) + .attr( 'x', 0 ) + .attr( 'y', 0 ) + .attr( 'width', params.xGroupScale.range()[ 1 ] ) + .attr( 'height', params.height ) + .attr( 'opacity', '0' ); + + barGroup + .selectAll( '.bar' ) + .data( d => + params.orderedKeys.filter( row => row.visible ).map( row => ( { + key: row.key, + focus: row.focus, + value: get( d, [ row.key, 'value' ], 0 ), + label: get( d, [ row.key, 'label' ], '' ), + visible: row.visible, + date: d.date, + } ) ) + ) + .enter() + .append( 'rect' ) + .attr( 'class', 'bar' ) + .attr( 'x', d => params.xGroupScale( d.key ) ) + .attr( 'y', d => params.yScale( d.value ) ) + .attr( 'width', params.xGroupScale.bandwidth() ) + .attr( 'height', d => params.height - params.yScale( d.value ) ) + .attr( 'fill', d => getColor( d.key, params ) ) + .attr( 'tabindex', '0' ) + .attr( 'aria-label', d => { + const label = params.mode === 'time-comparison' && d.label ? d.label : d.key; + return `${ label } ${ params.tooltipValueFormat( d.value ) }`; + } ) + .style( 'opacity', d => { + const opacity = d.focus ? 1 : 0.1; + return d.visible ? opacity : 0; + } ) + .on( 'focus', ( d, i, nodes ) => { + const targetNode = d.value > 0 ? d3Event.target : d3Event.target.parentNode; + const position = calculateTooltipPosition( targetNode, node.node(), params.tooltipPosition ); + handleMouseOverBarChart( d.date, nodes[ i ].parentNode, node, data, params, position ); + } ) + .on( 'blur', ( d, i, nodes ) => handleMouseOutBarChart( nodes[ i ].parentNode, params ) ); + + barGroup + .append( 'rect' ) + .attr( 'class', 'barmouse' ) + .attr( 'x', 0 ) + .attr( 'y', 0 ) + .attr( 'width', params.xGroupScale.range()[ 1 ] ) + .attr( 'height', params.height ) + .attr( 'opacity', '0' ) + .on( 'mouseover', ( d, i, nodes ) => { + const position = calculateTooltipPosition( + d3Event.target, + node.node(), + params.tooltipPosition + ); + handleMouseOverBarChart( d.date, nodes[ i ].parentNode, node, data, params, position ); + } ) + .on( 'mouseout', ( d, i, nodes ) => handleMouseOutBarChart( nodes[ i ].parentNode, params ) ); +}; diff --git a/plugins/woocommerce-admin/packages/components/src/chart/d3chart/utils/breakpoints.js b/plugins/woocommerce-admin/packages/components/src/chart/d3chart/utils/breakpoints.js new file mode 100644 index 00000000000..19b8b559da9 --- /dev/null +++ b/plugins/woocommerce-admin/packages/components/src/chart/d3chart/utils/breakpoints.js @@ -0,0 +1,3 @@ +/** @format */ +export const smallBreak = 783; +export const wideBreak = 1365; diff --git a/plugins/woocommerce-admin/packages/components/src/chart/d3chart/utils/color.js b/plugins/woocommerce-admin/packages/components/src/chart/d3chart/utils/color.js new file mode 100644 index 00000000000..1e2a42c922a --- /dev/null +++ b/plugins/woocommerce-admin/packages/components/src/chart/d3chart/utils/color.js @@ -0,0 +1,25 @@ +/** @format */ + +/** + * External dependencies + */ +import { findIndex } from 'lodash'; + +export const getColor = ( key, params ) => { + const smallColorScales = [ + [], + [ 0.5 ], + [ 0.333, 0.667 ], + [ 0.2, 0.5, 0.8 ], + [ 0.12, 0.375, 0.625, 0.88 ], + ]; + let keyValue = 0; + const len = params.orderedKeys.length; + const idx = findIndex( params.orderedKeys, d => d.key === key ); + if ( len < 5 ) { + keyValue = smallColorScales[ len ][ idx ]; + } else { + keyValue = idx / ( params.orderedKeys.length - 1 ); + } + return params.colorScheme( keyValue ); +}; diff --git a/plugins/woocommerce-admin/packages/components/src/chart/d3chart/utils/index.js b/plugins/woocommerce-admin/packages/components/src/chart/d3chart/utils/index.js new file mode 100644 index 00000000000..8dcbb4716f0 --- /dev/null +++ b/plugins/woocommerce-admin/packages/components/src/chart/d3chart/utils/index.js @@ -0,0 +1,136 @@ +/** @format */ + +/** + * External dependencies + */ +import { find, get } from 'lodash'; +import { format as d3Format } from 'd3-format'; +import { line as d3Line } from 'd3-shape'; + +/** + * Allows an overriding formatter or defaults to d3Format or d3TimeFormat + * @param {string|function} format - either a format string for the D3 formatters or an overriding fomatting method + * @param {function} formatter - default d3Format or another formatting method, which accepts the string `format` + * @returns {function} to be used to format an input given the format and formatter + */ +export const getFormatter = ( format, formatter = d3Format ) => + typeof format === 'function' ? format : formatter( format ); + +/** + * Describes `getUniqueKeys` + * @param {array} data - The chart component's `data` prop. + * @returns {array} of unique category keys + */ +export const getUniqueKeys = data => { + return [ + ...new Set( + data.reduce( ( accum, curr ) => { + Object.keys( curr ).forEach( key => key !== 'date' && accum.push( key ) ); + return accum; + }, [] ) + ), + ]; +}; + +/** + * Describes `getOrderedKeys` + * @param {array} data - The chart component's `data` prop. + * @param {array} uniqueKeys - from `getUniqueKeys`. + * @returns {array} of unique category keys ordered by cumulative total value + */ +export const getOrderedKeys = ( data, uniqueKeys ) => + uniqueKeys + .map( key => ( { + key, + focus: true, + total: data.reduce( ( a, c ) => a + c[ key ].value, 0 ), + visible: true, + } ) ) + .sort( ( a, b ) => b.total - a.total ); + +/** + * Describes `getLineData` + * @param {array} data - The chart component's `data` prop. + * @param {array} orderedKeys - from `getOrderedKeys`. + * @returns {array} an array objects with a category `key` and an array of `values` with `date` and `value` properties + */ +export const getLineData = ( data, orderedKeys ) => + orderedKeys.map( row => ( { + key: row.key, + focus: row.focus, + visible: row.visible, + values: data.map( d => ( { + date: d.date, + focus: row.focus, + label: get( d, [ row.key, 'label' ], '' ), + value: get( d, [ row.key, 'value' ], 0 ), + visible: row.visible, + } ) ), + } ) ); + +/** + * Describes `getUniqueDates` + * @param {array} lineData - from `GetLineData` + * @param {function} parseDate - D3 time format parser + * @returns {array} an array of unique date values sorted from earliest to latest + */ +export const getUniqueDates = ( lineData, parseDate ) => { + return [ + ...new Set( + lineData.reduce( ( accum, { values } ) => { + values.forEach( ( { date } ) => accum.push( date ) ); + return accum; + }, [] ) + ), + ].sort( ( a, b ) => parseDate( a ) - parseDate( b ) ); +}; + +/** + * Describes getLine + * @param {function} xLineScale - from `getXLineScale`. + * @param {function} yScale - from `getYScale`. + * @returns {function} the D3 line function for plotting all category values + */ +export const getLine = ( xLineScale, yScale ) => + d3Line() + .x( d => xLineScale( new Date( d.date ) ) ) + .y( d => yScale( d.value ) ); + +/** + * Describes getDateSpaces + * @param {array} data - The chart component's `data` prop. + * @param {array} uniqueDates - from `getUniqueDates` + * @param {number} width - calculated width of the charting space + * @param {function} xLineScale - from `getXLineScale` + * @returns {array} that icnludes the date, start (x position) and width to mode the mouseover rectangles + */ +export const getDateSpaces = ( data, uniqueDates, width, xLineScale ) => + uniqueDates.map( ( d, i ) => { + const datapoints = find( data, { date: d } ); + const xNow = xLineScale( new Date( d ) ); + const xPrev = + i >= 1 + ? xLineScale( new Date( uniqueDates[ i - 1 ] ) ) + : xLineScale( new Date( uniqueDates[ 0 ] ) ); + const xNext = + i < uniqueDates.length - 1 + ? xLineScale( new Date( uniqueDates[ i + 1 ] ) ) + : xLineScale( new Date( uniqueDates[ uniqueDates.length - 1 ] ) ); + let xWidth = i === 0 ? xNext - xNow : xNow - xPrev; + const xStart = i === 0 ? 0 : xNow - xWidth / 2; + xWidth = i === 0 || i === uniqueDates.length - 1 ? xWidth / 2 : xWidth; + return { + date: d, + start: uniqueDates.length > 1 ? xStart : 0, + width: uniqueDates.length > 1 ? xWidth : width, + values: Object.keys( datapoints ) + .filter( key => key !== 'date' ) + .map( key => { + return { + key, + value: datapoints[ key ].value, + date: d, + }; + } ), + }; + } ); diff --git a/plugins/woocommerce-admin/packages/components/src/chart/d3chart/utils/line-chart.js b/plugins/woocommerce-admin/packages/components/src/chart/d3chart/utils/line-chart.js new file mode 100644 index 00000000000..1613d702739 --- /dev/null +++ b/plugins/woocommerce-admin/packages/components/src/chart/d3chart/utils/line-chart.js @@ -0,0 +1,145 @@ +/** @format */ + +/** + * External dependencies + */ +import { event as d3Event, select as d3Select } from 'd3-selection'; +import { smallBreak, wideBreak } from './breakpoints'; + +/** + * Internal dependencies + */ +import { getColor } from './color'; +import { calculateTooltipPosition, showTooltip } from './tooltip'; + +const handleMouseOverLineChart = ( date, parentNode, node, data, params, position ) => { + d3Select( parentNode ) + .select( '.focus-grid' ) + .attr( 'opacity', '1' ); + showTooltip( params, data.find( e => e.date === date ), position ); +}; + +const handleMouseOutLineChart = ( parentNode, params ) => { + d3Select( parentNode ) + .select( '.focus-grid' ) + .attr( 'opacity', '0' ); + params.tooltip.style( 'visibility', 'hidden' ); +}; + +export const drawLines = ( node, data, params ) => { + const series = node + .append( 'g' ) + .attr( 'class', 'lines' ) + .selectAll( '.line-g' ) + .data( params.lineData.filter( d => d.visible ).reverse() ) + .enter() + .append( 'g' ) + .attr( 'class', 'line-g' ) + .attr( 'role', 'region' ) + .attr( 'aria-label', d => d.key ); + + let lineStroke = params.width <= wideBreak || params.uniqueDates.length > 50 ? 2 : 3; + lineStroke = params.width <= smallBreak ? 1.25 : lineStroke; + const dotRadius = params.width <= wideBreak ? 4 : 6; + + series + .append( 'path' ) + .attr( 'fill', 'none' ) + .attr( 'stroke-width', lineStroke ) + .attr( 'stroke-linejoin', 'round' ) + .attr( 'stroke-linecap', 'round' ) + .attr( 'stroke', d => getColor( d.key, params ) ) + .style( 'opacity', d => { + const opacity = d.focus ? 1 : 0.1; + return d.visible ? opacity : 0; + } ) + .attr( 'd', d => params.line( d.values ) ); + + const minDataPointSpacing = 36; + + params.width / params.uniqueDates.length > minDataPointSpacing && + series + .selectAll( 'circle' ) + .data( ( d, i ) => d.values.map( row => ( { ...row, i, visible: d.visible, key: d.key } ) ) ) + .enter() + .append( 'circle' ) + .attr( 'r', dotRadius ) + .attr( 'fill', d => getColor( d.key, params ) ) + .attr( 'stroke', '#fff' ) + .attr( 'stroke-width', lineStroke + 1 ) + .style( 'opacity', d => { + const opacity = d.focus ? 1 : 0.1; + return d.visible ? opacity : 0; + } ) + .attr( 'cx', d => params.xLineScale( new Date( d.date ) ) ) + .attr( 'cy', d => params.yScale( d.value ) ) + .attr( 'tabindex', '0' ) + .attr( 'aria-label', d => { + const label = d.label + ? d.label + : params.tooltipLabelFormat( d.date instanceof Date ? d.date : new Date( d.date ) ); + return `${ label } ${ params.tooltipValueFormat( d.value ) }`; + } ) + .on( 'focus', ( d, i, nodes ) => { + const position = calculateTooltipPosition( + d3Event.target, + node.node(), + params.tooltipPosition + ); + handleMouseOverLineChart( d.date, nodes[ i ].parentNode, node, data, params, position ); + } ) + .on( 'blur', ( d, i, nodes ) => handleMouseOutLineChart( nodes[ i ].parentNode, params ) ); + + const focus = node + .append( 'g' ) + .attr( 'class', 'focusspaces' ) + .selectAll( '.focus' ) + .data( params.dateSpaces ) + .enter() + .append( 'g' ) + .attr( 'class', 'focus' ); + + const focusGrid = focus + .append( 'g' ) + .attr( 'class', 'focus-grid' ) + .attr( 'opacity', '0' ); + + focusGrid + .append( 'line' ) + .attr( 'x1', d => params.xLineScale( new Date( d.date ) ) ) + .attr( 'y1', 0 ) + .attr( 'x2', d => params.xLineScale( new Date( d.date ) ) ) + .attr( 'y2', params.height ); + + focusGrid + .selectAll( 'circle' ) + .data( d => d.values.reverse() ) + .enter() + .append( 'circle' ) + .attr( 'r', dotRadius + 2 ) + .attr( 'fill', d => getColor( d.key, params ) ) + .attr( 'stroke', '#fff' ) + .attr( 'stroke-width', lineStroke + 2 ) + .attr( 'cx', d => params.xLineScale( new Date( d.date ) ) ) + .attr( 'cy', d => params.yScale( d.value ) ); + + focus + .append( 'rect' ) + .attr( 'class', 'focus-g' ) + .attr( 'x', d => d.start ) + .attr( 'y', 0 ) + .attr( 'width', d => d.width ) + .attr( 'height', params.height ) + .attr( 'opacity', 0 ) + .on( 'mouseover', ( d, i, nodes ) => { + const elementWidthRatio = i === 0 || i === params.dateSpaces.length - 1 ? 0 : 0.5; + const position = calculateTooltipPosition( + d3Event.target, + node.node(), + params.tooltipPosition, + elementWidthRatio + ); + handleMouseOverLineChart( d.date, nodes[ i ].parentNode, node, data, params, position ); + } ) + .on( 'mouseout', ( d, i, nodes ) => handleMouseOutLineChart( nodes[ i ].parentNode, params ) ); +}; diff --git a/plugins/woocommerce-admin/packages/components/src/chart/d3chart/utils/scales.js b/plugins/woocommerce-admin/packages/components/src/chart/d3chart/utils/scales.js new file mode 100644 index 00000000000..63b55d0a7ee --- /dev/null +++ b/plugins/woocommerce-admin/packages/components/src/chart/d3chart/utils/scales.js @@ -0,0 +1,79 @@ +/** @format */ + +/** + * External dependencies + */ +import { max as d3Max } from 'd3-array'; +import { + scaleBand as d3ScaleBand, + scaleLinear as d3ScaleLinear, + scaleTime as d3ScaleTime, +} from 'd3-scale'; + +/** + * Describes and rounds the maximum y value to the nearest thousand, ten-thousand, million etc. In case it is a decimal number, ceils it. + * @param {array} lineData - from `getLineData` + * @returns {number} the maximum value in the timeseries multiplied by 4/3 + */ +export const getYMax = lineData => { + const yMax = 4 / 3 * d3Max( lineData, d => d3Max( d.values.map( date => date.value ) ) ); + const pow3Y = Math.pow( 10, ( ( Math.log( yMax ) * Math.LOG10E + 1 ) | 0 ) - 2 ) * 3; + return Math.ceil( Math.ceil( yMax / pow3Y ) * pow3Y ); +}; + +/** + * Describes getXScale + * @param {array} uniqueDates - from `getUniqueDates` + * @param {number} width - calculated width of the charting space + * @returns {function} a D3 scale of the dates + */ +export const getXScale = ( uniqueDates, width ) => + d3ScaleBand() + .domain( uniqueDates ) + .rangeRound( [ 0, width ] ) + .paddingInner( 0.1 ); + +/** + * Describes getXGroupScale + * @param {array} orderedKeys - from `getOrderedKeys` + * @param {function} xScale - from `getXScale` + * @returns {function} a D3 scale for each category within the xScale range + */ +export const getXGroupScale = ( orderedKeys, xScale ) => + d3ScaleBand() + .domain( orderedKeys.filter( d => d.visible ).map( d => d.key ) ) + .rangeRound( [ 0, xScale.bandwidth() ] ) + .padding( 0.07 ); + +/** + * Describes getXLineScale + * @param {array} uniqueDates - from `getUniqueDates` + * @param {number} width - calculated width of the charting space + * @returns {function} a D3 scaletime for each date + */ +export const getXLineScale = ( uniqueDates, width ) => + d3ScaleTime() + .domain( [ new Date( uniqueDates[ 0 ] ), new Date( uniqueDates[ uniqueDates.length - 1 ] ) ] ) + .rangeRound( [ 0, width ] ); + +/** + * Describes getYScale + * @param {number} height - calculated height of the charting space + * @param {number} yMax - from `getYMax` + * @returns {function} the D3 linear scale from 0 to the value from `getYMax` + */ +export const getYScale = ( height, yMax ) => + d3ScaleLinear() + .domain( [ 0, yMax ] ) + .rangeRound( [ height, 0 ] ); + +/** + * Describes getyTickOffset + * @param {number} height - calculated height of the charting space + * @param {number} yMax - from `getYMax` + * @returns {function} the D3 linear scale from 0 to the value from `getYMax`, offset by 12 pixels down + */ +export const getYTickOffset = ( height, yMax ) => + d3ScaleLinear() + .domain( [ 0, yMax ] ) + .rangeRound( [ height + 12, 12 ] ); diff --git a/plugins/woocommerce-admin/packages/components/src/chart/d3chart/test/utils.js b/plugins/woocommerce-admin/packages/components/src/chart/d3chart/utils/test/axis.js similarity index 51% rename from plugins/woocommerce-admin/packages/components/src/chart/d3chart/test/utils.js rename to plugins/woocommerce-admin/packages/components/src/chart/d3chart/utils/test/axis.js index d29ed740b6e..7a19250c93f 100644 --- a/plugins/woocommerce-admin/packages/components/src/chart/d3chart/test/utils.js +++ b/plugins/woocommerce-admin/packages/components/src/chart/d3chart/utils/test/axis.js @@ -1,162 +1,12 @@ +/** @format */ /** * External dependencies - * - * @format */ -// import { noop } from 'lodash'; -import { utcParse as d3UTCParse } from 'd3-time-format'; /** - * Internal dependencies - */ -import dummyOrders from './fixtures/dummy'; -import { - compareStrings, - getDateSpaces, - getOrderedKeys, - getLineData, - getUniqueKeys, - getUniqueDates, - getXScale, - getXGroupScale, - getXLineScale, - getXTicks, - getYMax, - getYScale, - getYTickOffset, -} from '../utils'; - -const orderedKeys = [ - { - key: 'Cap', - focus: true, - visible: true, - total: 34513697, - }, - { - key: 'T-Shirt', - focus: true, - visible: true, - total: 14762281, - }, - { - key: 'Sunglasses', - focus: true, - visible: true, - total: 12430349, - }, - { - key: 'Polo', - focus: true, - visible: true, - total: 8712807, - }, - { - key: 'Hoodie', - focus: true, - visible: true, - total: 6968764, - }, -]; -const orderedDates = [ - '2018-05-30T00:00:00', - '2018-05-31T00:00:00', - '2018-06-01T00:00:00', - '2018-06-02T00:00:00', - '2018-06-03T00:00:00', - '2018-06-04T00:00:00', -]; -const parseDate = d3UTCParse( '%Y-%m-%dT%H:%M:%S' ); -const testUniqueKeys = getUniqueKeys( dummyOrders ); -const testOrderedKeys = getOrderedKeys( dummyOrders, testUniqueKeys ); -const testLineData = getLineData( dummyOrders, testOrderedKeys ); -const testUniqueDates = getUniqueDates( testLineData, parseDate ); -const testXScale = getXScale( testUniqueDates, 100 ); -const testXLineScale = getXLineScale( testUniqueDates, 100 ); -const testYMax = getYMax( testLineData ); -const testYScale = getYScale( 100, testYMax ); - -describe( 'parseDate', () => { - it( 'correctly parse date in the expected format', () => { - const testDate = parseDate( '2018-06-30T00:00:00' ); - const expectedDate = new Date( Date.UTC( 2018, 5, 30 ) ); - expect( testDate.getTime() ).toEqual( expectedDate.getTime() ); - } ); -} ); - -describe( 'getUniqueKeys', () => { - it( 'returns an array of keys excluding date', () => { - // sort is a mutating action so we need a copy - const testUniqueKeysClone = testUniqueKeys.slice(); - const sortedAZKeys = orderedKeys.map( d => d.key ).slice(); - expect( testUniqueKeysClone.sort() ).toEqual( sortedAZKeys.sort() ); - } ); -} ); - -describe( 'getOrderedKeys', () => { - it( 'returns an array of keys order by value from largest to smallest', () => { - expect( testOrderedKeys ).toEqual( orderedKeys ); - } ); -} ); - -describe( 'getLineData', () => { - it( 'returns a sorted array of objects with category key', () => { - expect( testLineData ).toBeInstanceOf( Array ); - expect( testLineData ).toHaveLength( 5 ); - expect( testLineData.map( d => d.key ) ).toEqual( orderedKeys.map( d => d.key ) ); - } ); - - testLineData.forEach( d => { - it( 'ensure a key and that the values property is an array', () => { - expect( d ).toHaveProperty( 'key' ); - expect( d ).toHaveProperty( 'values' ); - expect( d.values ).toBeInstanceOf( Array ); - } ); - - it( 'ensure all unique dates exist in values array', () => { - const rowDates = d.values.map( row => row.date ); - expect( rowDates ).toEqual( orderedDates ); - } ); - - d.values.forEach( row => { - it( 'ensure a date property and that the values property is an array with date (parseable) and value properties', () => { - expect( row ).toHaveProperty( 'date' ); - expect( row ).toHaveProperty( 'value' ); - expect( parseDate( row.date ) ).not.toBeNull(); - expect( typeof row.date ).toBe( 'string' ); - expect( typeof row.value ).toBe( 'number' ); - } ); - } ); - } ); -} ); - -describe( 'getXScale', () => { - it( 'properly scale inputs to the provided domain and range', () => { - expect( testXScale( orderedDates[ 0 ] ) ).toEqual( 3 ); - expect( testXScale( orderedDates[ 2 ] ) ).toEqual( 35 ); - expect( testXScale( orderedDates[ orderedDates.length - 1 ] ) ).toEqual( 83 ); - } ); - it( 'properly scale inputs and test the bandwidth', () => { - expect( testXScale.bandwidth() ).toEqual( 14 ); - } ); -} ); - -describe( 'getXGroupScale', () => { - it( 'properly scale inputs based on the getXScale', () => { - const testXGroupScale = getXGroupScale( testOrderedKeys, testXScale ); - expect( testXGroupScale( orderedKeys[ 0 ].key ) ).toEqual( 2 ); - expect( testXGroupScale( orderedKeys[ 2 ].key ) ).toEqual( 6 ); - expect( testXGroupScale( orderedKeys[ orderedKeys.length - 1 ].key ) ).toEqual( 10 ); - } ); -} ); - -describe( 'getXLineScale', () => { - it( 'properly scale inputs for the line', () => { - expect( testXLineScale( new Date( orderedDates[ 0 ] ) ) ).toEqual( 0 ); - expect( testXLineScale( new Date( orderedDates[ 2 ] ) ) ).toEqual( 40 ); - expect( testXLineScale( new Date( orderedDates[ orderedDates.length - 1 ] ) ) ).toEqual( 100 ); - } ); -} ); +* Internal dependencies +*/ +import { compareStrings, getXTicks } from '../axis'; describe( 'getXTicks', () => { describe( 'interval=day', () => { @@ -380,42 +230,6 @@ describe( 'getXTicks', () => { } ); } ); -describe( 'getYMax', () => { - it( 'calculate the correct maximum y value', () => { - expect( testYMax ).toEqual( 15000000 ); - } ); -} ); - -describe( 'getYScale', () => { - it( 'properly scale the y values given the height and maximum y value', () => { - expect( testYScale( 0 ) ).toEqual( 100 ); - expect( testYScale( testYMax ) ).toEqual( 0 ); - } ); -} ); - -describe( 'getYTickOffset', () => { - it( 'properly scale the y values for the y-axis ticks given the height and maximum y value', () => { - const testYTickOffset1 = getYTickOffset( 100, testYMax ); - expect( testYTickOffset1( 0 ) ).toEqual( 112 ); - expect( testYTickOffset1( testYMax ) ).toEqual( 12 ); - } ); -} ); - -describe( 'getDateSpaces', () => { - it( 'return an array used to space out the mouseover rectangles, used for tooltips', () => { - const testDateSpaces = getDateSpaces( dummyOrders, testUniqueDates, 100, testXLineScale ); - expect( testDateSpaces[ 0 ].date ).toEqual( '2018-05-30T00:00:00' ); - expect( testDateSpaces[ 0 ].start ).toEqual( 0 ); - expect( testDateSpaces[ 0 ].width ).toEqual( 10 ); - expect( testDateSpaces[ 3 ].date ).toEqual( '2018-06-02T00:00:00' ); - expect( testDateSpaces[ 3 ].start ).toEqual( 50 ); - expect( testDateSpaces[ 3 ].width ).toEqual( 20 ); - expect( testDateSpaces[ testDateSpaces.length - 1 ].date ).toEqual( '2018-06-04T00:00:00' ); - expect( testDateSpaces[ testDateSpaces.length - 1 ].start ).toEqual( 90 ); - expect( testDateSpaces[ testDateSpaces.length - 1 ].width ).toEqual( 10 ); - } ); -} ); - describe( 'compareStrings', () => { it( 'return an array of unique words from s2 that dont appear in base string', () => { expect( compareStrings( 'Jul 2018', 'Aug 2018' ).join( ' ' ) ).toEqual( 'Aug' ); diff --git a/plugins/woocommerce-admin/packages/components/src/chart/d3chart/utils/test/fixtures/dummy-ordered-dates.js b/plugins/woocommerce-admin/packages/components/src/chart/d3chart/utils/test/fixtures/dummy-ordered-dates.js new file mode 100644 index 00000000000..85839f2932d --- /dev/null +++ b/plugins/woocommerce-admin/packages/components/src/chart/d3chart/utils/test/fixtures/dummy-ordered-dates.js @@ -0,0 +1,9 @@ +/** @format */ +export default [ + '2018-05-30T00:00:00', + '2018-05-31T00:00:00', + '2018-06-01T00:00:00', + '2018-06-02T00:00:00', + '2018-06-03T00:00:00', + '2018-06-04T00:00:00', +]; diff --git a/plugins/woocommerce-admin/packages/components/src/chart/d3chart/utils/test/fixtures/dummy-ordered-keys.js b/plugins/woocommerce-admin/packages/components/src/chart/d3chart/utils/test/fixtures/dummy-ordered-keys.js new file mode 100644 index 00000000000..713e7ea877f --- /dev/null +++ b/plugins/woocommerce-admin/packages/components/src/chart/d3chart/utils/test/fixtures/dummy-ordered-keys.js @@ -0,0 +1,33 @@ +/** @format */ +export default [ + { + key: 'Cap', + focus: true, + visible: true, + total: 34513697, + }, + { + key: 'T-Shirt', + focus: true, + visible: true, + total: 14762281, + }, + { + key: 'Sunglasses', + focus: true, + visible: true, + total: 12430349, + }, + { + key: 'Polo', + focus: true, + visible: true, + total: 8712807, + }, + { + key: 'Hoodie', + focus: true, + visible: true, + total: 6968764, + }, +]; diff --git a/plugins/woocommerce-admin/packages/components/src/chart/d3chart/test/fixtures/dummy.js b/plugins/woocommerce-admin/packages/components/src/chart/d3chart/utils/test/fixtures/dummy-orders.js similarity index 95% rename from plugins/woocommerce-admin/packages/components/src/chart/d3chart/test/fixtures/dummy.js rename to plugins/woocommerce-admin/packages/components/src/chart/d3chart/utils/test/fixtures/dummy-orders.js index 6e1488316ed..9f825544f07 100644 --- a/plugins/woocommerce-admin/packages/components/src/chart/d3chart/test/fixtures/dummy.js +++ b/plugins/woocommerce-admin/packages/components/src/chart/d3chart/utils/test/fixtures/dummy-orders.js @@ -1,11 +1,4 @@ /** @format */ - -/** - * /* eslint-disable quote-props - * - * @format - */ - export default [ { date: '2018-05-30T00:00:00', diff --git a/plugins/woocommerce-admin/packages/components/src/chart/d3chart/utils/test/index.js b/plugins/woocommerce-admin/packages/components/src/chart/d3chart/utils/test/index.js new file mode 100644 index 00000000000..85bae47e55d --- /dev/null +++ b/plugins/woocommerce-admin/packages/components/src/chart/d3chart/utils/test/index.js @@ -0,0 +1,96 @@ +/** @format */ +/** + * External dependencies + */ +import { utcParse as d3UTCParse } from 'd3-time-format'; + +/** + * Internal dependencies + */ +import dummyOrders from './fixtures/dummy-orders'; +import orderedDates from './fixtures/dummy-ordered-dates'; +import orderedKeys from './fixtures/dummy-ordered-keys'; +import { + getDateSpaces, + getOrderedKeys, + getLineData, + getUniqueKeys, + getUniqueDates, +} from '../index'; +import { getXLineScale } from '../scales'; + +const parseDate = d3UTCParse( '%Y-%m-%dT%H:%M:%S' ); +const testUniqueKeys = getUniqueKeys( dummyOrders ); +const testOrderedKeys = getOrderedKeys( dummyOrders, testUniqueKeys ); +const testLineData = getLineData( dummyOrders, testOrderedKeys ); +const testUniqueDates = getUniqueDates( testLineData, parseDate ); +const testXLineScale = getXLineScale( testUniqueDates, 100 ); + +describe( 'parseDate', () => { + it( 'correctly parse date in the expected format', () => { + const testDate = parseDate( '2018-06-30T00:00:00' ); + const expectedDate = new Date( Date.UTC( 2018, 5, 30 ) ); + expect( testDate.getTime() ).toEqual( expectedDate.getTime() ); + } ); +} ); + +describe( 'getUniqueKeys', () => { + it( 'returns an array of keys excluding date', () => { + // sort is a mutating action so we need a copy + const testUniqueKeysClone = testUniqueKeys.slice(); + const sortedAZKeys = orderedKeys.map( d => d.key ).slice(); + expect( testUniqueKeysClone.sort() ).toEqual( sortedAZKeys.sort() ); + } ); +} ); + +describe( 'getOrderedKeys', () => { + it( 'returns an array of keys order by value from largest to smallest', () => { + expect( testOrderedKeys ).toEqual( orderedKeys ); + } ); +} ); + +describe( 'getLineData', () => { + it( 'returns a sorted array of objects with category key', () => { + expect( testLineData ).toBeInstanceOf( Array ); + expect( testLineData ).toHaveLength( 5 ); + expect( testLineData.map( d => d.key ) ).toEqual( orderedKeys.map( d => d.key ) ); + } ); + + testLineData.forEach( d => { + it( 'ensure a key and that the values property is an array', () => { + expect( d ).toHaveProperty( 'key' ); + expect( d ).toHaveProperty( 'values' ); + expect( d.values ).toBeInstanceOf( Array ); + } ); + + it( 'ensure all unique dates exist in values array', () => { + const rowDates = d.values.map( row => row.date ); + expect( rowDates ).toEqual( orderedDates ); + } ); + + d.values.forEach( row => { + it( 'ensure a date property and that the values property is an array with date (parseable) and value properties', () => { + expect( row ).toHaveProperty( 'date' ); + expect( row ).toHaveProperty( 'value' ); + expect( parseDate( row.date ) ).not.toBeNull(); + expect( typeof row.date ).toBe( 'string' ); + expect( typeof row.value ).toBe( 'number' ); + } ); + } ); + } ); +} ); + +describe( 'getDateSpaces', () => { + it( 'return an array used to space out the mouseover rectangles, used for tooltips', () => { + const testDateSpaces = getDateSpaces( dummyOrders, testUniqueDates, 100, testXLineScale ); + expect( testDateSpaces[ 0 ].date ).toEqual( '2018-05-30T00:00:00' ); + expect( testDateSpaces[ 0 ].start ).toEqual( 0 ); + expect( testDateSpaces[ 0 ].width ).toEqual( 10 ); + expect( testDateSpaces[ 3 ].date ).toEqual( '2018-06-02T00:00:00' ); + expect( testDateSpaces[ 3 ].start ).toEqual( 50 ); + expect( testDateSpaces[ 3 ].width ).toEqual( 20 ); + expect( testDateSpaces[ testDateSpaces.length - 1 ].date ).toEqual( '2018-06-04T00:00:00' ); + expect( testDateSpaces[ testDateSpaces.length - 1 ].start ).toEqual( 90 ); + expect( testDateSpaces[ testDateSpaces.length - 1 ].width ).toEqual( 10 ); + } ); +} ); diff --git a/plugins/woocommerce-admin/packages/components/src/chart/d3chart/utils/test/scales.js b/plugins/woocommerce-admin/packages/components/src/chart/d3chart/utils/test/scales.js new file mode 100644 index 00000000000..346d727c5db --- /dev/null +++ b/plugins/woocommerce-admin/packages/components/src/chart/d3chart/utils/test/scales.js @@ -0,0 +1,78 @@ +/** @format */ +/** + * External dependencies + */ +import { utcParse as d3UTCParse } from 'd3-time-format'; + +/** + * Internal dependencies + */ +import dummyOrders from './fixtures/dummy-orders'; +import orderedDates from './fixtures/dummy-ordered-dates'; +import orderedKeys from './fixtures/dummy-ordered-keys'; +import { + getOrderedKeys, + getLineData, + getUniqueKeys, + getUniqueDates, +} from '../index'; +import { getXGroupScale, getXScale, getXLineScale, getYMax, getYScale, getYTickOffset } from '../scales'; + +const parseDate = d3UTCParse( '%Y-%m-%dT%H:%M:%S' ); +const testUniqueKeys = getUniqueKeys( dummyOrders ); +const testOrderedKeys = getOrderedKeys( dummyOrders, testUniqueKeys ); +const testLineData = getLineData( dummyOrders, testOrderedKeys ); +const testUniqueDates = getUniqueDates( testLineData, parseDate ); +const testXScale = getXScale( testUniqueDates, 100 ); +const testXLineScale = getXLineScale( testUniqueDates, 100 ); +const testYMax = getYMax( testLineData ); +const testYScale = getYScale( 100, testYMax ); + +describe( 'getXScale', () => { + it( 'properly scale inputs to the provided domain and range', () => { + expect( testXScale( orderedDates[ 0 ] ) ).toEqual( 3 ); + expect( testXScale( orderedDates[ 2 ] ) ).toEqual( 35 ); + expect( testXScale( orderedDates[ orderedDates.length - 1 ] ) ).toEqual( 83 ); + } ); + it( 'properly scale inputs and test the bandwidth', () => { + expect( testXScale.bandwidth() ).toEqual( 14 ); + } ); +} ); + +describe( 'getXGroupScale', () => { + it( 'properly scale inputs based on the getXScale', () => { + const testXGroupScale = getXGroupScale( testOrderedKeys, testXScale ); + expect( testXGroupScale( orderedKeys[ 0 ].key ) ).toEqual( 2 ); + expect( testXGroupScale( orderedKeys[ 2 ].key ) ).toEqual( 6 ); + expect( testXGroupScale( orderedKeys[ orderedKeys.length - 1 ].key ) ).toEqual( 10 ); + } ); +} ); + +describe( 'getXLineScale', () => { + it( 'properly scale inputs for the line', () => { + expect( testXLineScale( new Date( orderedDates[ 0 ] ) ) ).toEqual( 0 ); + expect( testXLineScale( new Date( orderedDates[ 2 ] ) ) ).toEqual( 40 ); + expect( testXLineScale( new Date( orderedDates[ orderedDates.length - 1 ] ) ) ).toEqual( 100 ); + } ); +} ); + +describe( 'getYMax', () => { + it( 'calculate the correct maximum y value', () => { + expect( testYMax ).toEqual( 15000000 ); + } ); +} ); + +describe( 'getYScale', () => { + it( 'properly scale the y values given the height and maximum y value', () => { + expect( testYScale( 0 ) ).toEqual( 100 ); + expect( testYScale( testYMax ) ).toEqual( 0 ); + } ); +} ); + +describe( 'getYTickOffset', () => { + it( 'properly scale the y values for the y-axis ticks given the height and maximum y value', () => { + const testYTickOffset1 = getYTickOffset( 100, testYMax ); + expect( testYTickOffset1( 0 ) ).toEqual( 112 ); + expect( testYTickOffset1( testYMax ) ).toEqual( 12 ); + } ); +} ); diff --git a/plugins/woocommerce-admin/packages/components/src/chart/d3chart/utils/tooltip.js b/plugins/woocommerce-admin/packages/components/src/chart/d3chart/utils/tooltip.js new file mode 100644 index 00000000000..43797ffe3c2 --- /dev/null +++ b/plugins/woocommerce-admin/packages/components/src/chart/d3chart/utils/tooltip.js @@ -0,0 +1,137 @@ +/** @format */ + +/** + * External dependencies + */ +import { select as d3Select } from 'd3-selection'; + +/** + * Internal dependencies + */ +import { getColor } from './color'; + +const calculateTooltipXPosition = ( + elementCoords, + chartCoords, + tooltipSize, + tooltipMargin, + elementWidthRatio, + tooltipPosition +) => { + const xPosition = + elementCoords.left + elementCoords.width * elementWidthRatio + tooltipMargin - chartCoords.left; + + if ( tooltipPosition === 'below' ) { + return Math.max( + tooltipMargin, + Math.min( + xPosition - tooltipSize.width / 2, + chartCoords.width - tooltipSize.width - tooltipMargin + ) + ); + } + + if ( xPosition + tooltipSize.width + tooltipMargin > chartCoords.width ) { + return Math.max( + tooltipMargin, + elementCoords.left + + elementCoords.width * ( 1 - elementWidthRatio ) - + tooltipSize.width - + tooltipMargin - + chartCoords.left + ); + } + + return xPosition; +}; + +const calculateTooltipYPosition = ( + elementCoords, + chartCoords, + tooltipSize, + tooltipMargin, + tooltipPosition +) => { + if ( tooltipPosition === 'below' ) { + return chartCoords.height; + } + + const yPosition = elementCoords.top + tooltipMargin - chartCoords.top; + if ( yPosition + tooltipSize.height + tooltipMargin > chartCoords.height ) { + return Math.max( 0, elementCoords.top - tooltipSize.height - tooltipMargin - chartCoords.top ); + } + + return yPosition; +}; + +export const calculateTooltipPosition = ( element, chart, tooltipPosition, elementWidthRatio = 1 ) => { + const elementCoords = element.getBoundingClientRect(); + const chartCoords = chart.getBoundingClientRect(); + const tooltipSize = d3Select( '.d3-chart__tooltip' ) + .node() + .getBoundingClientRect(); + const tooltipMargin = 24; + + if ( tooltipPosition === 'below' ) { + elementWidthRatio = 0; + } + + return { + x: calculateTooltipXPosition( + elementCoords, + chartCoords, + tooltipSize, + tooltipMargin, + elementWidthRatio, + tooltipPosition + ), + y: calculateTooltipYPosition( + elementCoords, + chartCoords, + tooltipSize, + tooltipMargin, + tooltipPosition + ), + }; +}; + +const getTooltipRowLabel = ( d, row, params ) => { + if ( d[ row.key ].labelDate ) { + return params.tooltipLabelFormat( + d[ row.key ].labelDate instanceof Date + ? d[ row.key ].labelDate + : new Date( d[ row.key ].labelDate ) + ); + } + return row.key; +}; + +export const showTooltip = ( params, d, position ) => { + const keys = params.orderedKeys.filter( row => row.visible ).map( + row => ` +
  • +
    + + ${ getTooltipRowLabel( d, row, params ) } +
    + ${ params.tooltipValueFormat( d[ row.key ].value ) } +
  • + ` + ); + + const tooltipTitle = params.tooltipTitle + ? params.tooltipTitle + : params.tooltipLabelFormat( d.date instanceof Date ? d.date : new Date( d.date ) ); + + params.tooltip + .style( 'left', position.x + 'px' ) + .style( 'top', position.y + 'px' ) + .style( 'visibility', 'visible' ).html( ` +
    +

    ${ tooltipTitle }

    + +
    + ` ); +};