UNPKG

lucid-ui

Version:

A UI component library from AppNexus.

482 lines (471 loc) 24.3 kB
import _ from 'lodash'; import React from 'react'; import PropTypes from 'react-peek/prop-types'; import { lucidClassNames } from '../../util/style-helpers'; import { getFirst, omitProps, } from '../../util/component-types'; import { minByFields, maxByFields, maxByFieldsStacked, formatDate, nearest, } from '../../util/chart-helpers'; import * as d3Scale from 'd3-scale'; import * as d3TimeFormat from 'd3-time-format'; import * as chartConstants from '../../constants/charts'; import Axis from '../Axis/Axis'; import AxisLabel from '../AxisLabel/AxisLabel'; import Legend from '../Legend/Legend'; import Lines from '../Lines/Lines'; import Points from '../Points/Points'; import { ToolTipDumb as ToolTip } from '../ToolTip/ToolTip'; import ContextMenu from '../ContextMenu/ContextMenu'; import EmptyStateWrapper from '../EmptyStateWrapper/EmptyStateWrapper'; const cx = lucidClassNames.bind('&-LineChart'); const { arrayOf, func, instanceOf, number, object, shape, string, bool, oneOfType, oneOf, } = PropTypes; class LineChart extends React.Component { constructor() { super(...arguments); this.state = { isHovering: false, mouseX: undefined, }; this.handleToolTipHoverZone = ({ clientX, target }, xPoints) => { const mouseX = nearest(xPoints, clientX - target.getBoundingClientRect().left); if (!this.state.isHovering || this.state.mouseX !== mouseX) { this.setState({ isHovering: true, mouseX: nearest(xPoints, clientX - target.getBoundingClientRect().left), }); } }; this.renderY2Axis = (xScale, y2Scale, y2AxisFinalFormatter, margin) => { const { y2AxisFields, yAxisFields, y2AxisTickCount, y2AxisTitle, y2AxisTitleColor, palette, xAxisField, y2AxisMax, data, y2AxisIsStacked, y2AxisColorOffset, colorMap, y2AxisHasPoints, } = this.props; /* y2 axis */ const axis = y2AxisFields ? (React.createElement("g", { transform: `translate(${margin.left + innerWidth}, ${margin.top})` }, React.createElement(Axis, { orient: 'right', scale: y2Scale, tickFormat: y2AxisFinalFormatter, tickCount: y2AxisTickCount }))) : null; /* y2 axis title */ const axisTitle = y2AxisTitle ? (React.createElement("g", { transform: `translate(${margin.left + innerWidth}, ${margin.top})` }, React.createElement(AxisLabel, { orient: 'right', width: margin.right, height: innerHeight, label: y2AxisTitle, color: _.isString(y2AxisTitleColor) ? y2AxisTitleColor : palette[y2AxisTitleColor % palette.length] }))) : null; const axisLines = y2AxisFields ? (React.createElement("g", { transform: `translate(${margin.left}, ${margin.top})` }, React.createElement(Lines, { xScale: xScale, yScale: y2Scale, xField: xAxisField, yFields: y2AxisFields, yStackedMax: y2AxisMax, data: data || {}, isStacked: y2AxisIsStacked, colorOffset: y2AxisColorOffset + yAxisFields.length, colorMap: colorMap, palette: palette }))) : null; const axisPoints = y2AxisFields && y2AxisHasPoints ? (React.createElement("g", { transform: `translate(${margin.left}, ${margin.top})` }, React.createElement(Points, { xScale: xScale, yScale: y2Scale, xField: xAxisField, yFields: y2AxisFields, yStackedMax: y2AxisMax, data: data || {}, isStacked: y2AxisIsStacked, colorOffset: y2AxisColorOffset + yAxisFields.length, colorMap: colorMap, palette: palette }))) : null; return { title: axisTitle, lines: axisLines, points: axisPoints, axis: axis, }; }; } render() { const { className, height, width, margin: marginOriginal, data, legend, isLoading, hasToolTips, hasLegend, palette, colorMap, xAxisField, xAxisTickCount, xAxisTicks, xAxisTitle, xAxisTitleColor, xAxisFormatter, xAxisTooltipFormatter, xAxisMin = minByFields(data, xAxisField), xAxisMax = maxByFields(data, xAxisField), xAxisTextOrientation, yAxisFields, yAxisFormatter, yAxisHasPoints, yAxisIsStacked, yAxisTickCount, yAxisTitle, yAxisTitleColor, yAxisMin, yAxisTooltipFormatter, yAxisTooltipDataFormatter, yAxisMax = (yAxisIsStacked ? maxByFieldsStacked(data, yAxisFields) : maxByFields(data, yAxisFields)), yAxisColorOffset, y2AxisFields, y2AxisFormatter, y2AxisTooltipDataFormatter, y2AxisHasPoints, y2AxisIsStacked, y2AxisMin, y2AxisMax = (y2AxisFields && y2AxisIsStacked ? maxByFieldsStacked(data, y2AxisFields) : maxByFields(data, y2AxisFields)), y2AxisColorOffset, yAxisTextOrientation, ...passThroughs } = this.props; const { isHovering, mouseX } = this.state; const margin = { ...LineChart.MARGIN, ...marginOriginal, }; const svgClasses = cx(className, '&'); const innerWidth = width - margin.left - margin.right; const innerHeight = height - margin.top - margin.bottom; /** * x axis */ const xScale = d3Scale .scaleTime() .domain([xAxisMin, xAxisMax]) .range([0, innerWidth]); const xFinalFormatter = xAxisFormatter ? xAxisFormatter : xScale.tickFormat(); const allYFields = _.compact(yAxisFields.concat(y2AxisFields)); // This is used to map x mouse values back to data points. const xPointMap = _.reduce(data, (acc, d) => { // `floor` to avoid rounding errors, it doesn't need to be super precise // since we're dealing with pixels const point = Math.floor(xScale(d[xAxisField])); _.each(allYFields, field => { _.set(acc, `${point}.y.${field}`, d[field]); _.set(acc, `${point}.x`, d[xAxisField]); }); return acc; }, {}); const xPoints = _.map(_.keys(xPointMap), _.toNumber); /** * y axis */ const yScale = d3Scale .scaleLinear() .domain([yAxisMin, yAxisMax]) .range([innerHeight, 0]); const yAxisFinalFormatter = yAxisFormatter || yScale.tickFormat(); const yFinalFormatter = yAxisTooltipDataFormatter ? yAxisTooltipDataFormatter : yAxisFinalFormatter; const yAxisHasLinesFinal = !(yAxisIsStacked && !yAxisHasPoints); const yAxisHasPointsFinal = yAxisHasPoints || yAxisIsStacked; /** * y2 axis */ let y2Axis = {}; let y2AxisLegend = null; let y2AxisToolTip = null; if (y2AxisFields) { const y2Scale = d3Scale .scaleLinear() .domain([y2AxisMin, y2AxisMax]) .range([innerHeight, 0]); const y2AxisFinalFormatter = y2AxisFormatter ? y2AxisFormatter : y2Scale ? y2Scale.tickFormat() : _.identity; const y2FinalFormatter = y2AxisTooltipDataFormatter ? y2AxisTooltipDataFormatter : y2AxisFinalFormatter; const y2AxisHasPointsFinal = y2AxisHasPoints || y2AxisIsStacked; const y2AxisHasLinesFinal = !(y2AxisIsStacked && !y2AxisHasPoints); y2Axis = this.renderY2Axis(xScale, y2Scale, y2AxisFinalFormatter, margin); y2AxisLegend = _.map(y2AxisFields, (field, index) => (React.createElement(Legend.Item, { key: index, hasPoint: y2AxisHasPointsFinal, hasLine: y2AxisHasLinesFinal, color: _.get(colorMap, field, palette[y2AxisColorOffset + index + (yAxisFields.length % palette.length)]), pointKind: y2AxisHasPoints ? y2AxisColorOffset + index + yAxisFields.length : 1 }, _.get(legend, field, field)))); y2AxisToolTip = _.map(y2AxisFields, (field, index) => !_.isNil(_.get(xPointMap, mouseX + '.y.' + field)) ? (React.createElement(Legend.Item, { key: index, hasPoint: y2AxisHasPointsFinal, hasLine: y2AxisHasLinesFinal, color: _.get(colorMap, field, palette[y2AxisColorOffset + index + (yAxisFields.length % palette.length)]), pointKind: y2AxisHasPoints ? y2AxisColorOffset + index + yAxisFields.length : 1 }, yAxisTooltipFormatter(_.get(legend, field, field), y2FinalFormatter(_.get(xPointMap, mouseX + '.y.' + field)), _.get(xPointMap, mouseX + '.y.' + field)))) : null); } if (_.isEmpty(data) || width < 1 || height < 1 || isLoading) { const emptyStateWrapper = getFirst(this.props, LineChart.EmptyStateWrapper) || React.createElement(LineChart.EmptyStateWrapper, { Title: 'You have no data.' }); const emptyStateWrapperProps = _.get(emptyStateWrapper, 'props', {}); const emptyStateWrapperChildren = _.get(emptyStateWrapperProps, 'children', []); return (React.createElement(EmptyStateWrapper, Object.assign({}, emptyStateWrapperProps, { isEmpty: _.isEmpty(data), isLoading: isLoading }), emptyStateWrapperChildren, React.createElement("svg", Object.assign({}, omitProps(passThroughs, undefined, _.keys(LineChart.propTypes)), { className: svgClasses, width: width, height: height }), React.createElement("g", { transform: `translate(${margin.left}, ${margin.top})` }, React.createElement(Axis, { orient: 'left', scale: yScale, tickFormat: yAxisFormatter })), React.createElement("g", { transform: `translate(${margin.left}, ${innerHeight + margin.top})` }, React.createElement(Axis, { orient: 'bottom', scale: xScale, tickFormat: xFinalFormatter }))))); } return (React.createElement("svg", Object.assign({}, omitProps(passThroughs, undefined, _.keys(LineChart.propTypes)), { className: svgClasses, width: width, height: height }), React.createElement("g", { transform: `translate(${margin.left}, ${margin.top})` }, hasToolTips && isHovering && !_.isNil(mouseX) ? (React.createElement(ToolTip, { isLight: true, isExpanded: true, flyOutMaxWidth: 'none', alignment: mouseX < innerWidth * 0.15 ? 'start' : mouseX > innerWidth * 0.85 ? 'end' : 'center' }, React.createElement(ToolTip.Target, { elementType: 'g' }, React.createElement("path", { className: cx('&-tooltip-line'), d: `M${mouseX},0 L${mouseX},${innerHeight}` })), React.createElement(ToolTip.Title, null, xAxisTooltipFormatter(_.get(xPointMap, `${mouseX}.x`))), React.createElement(ToolTip.Body, null, React.createElement(Legend, { hasBorders: false, isReversed: yAxisIsStacked }, _.map(yAxisFields, (field, index) => !_.isNil(_.get(xPointMap, mouseX + '.y.' + field)) ? (React.createElement(Legend.Item, { key: index, hasPoint: yAxisHasPointsFinal, hasLine: yAxisHasLinesFinal, color: _.get(colorMap, field, palette[(index + yAxisColorOffset) % palette.length]), pointKind: yAxisHasPoints ? index + yAxisColorOffset : 1 }, yAxisTooltipFormatter(_.get(legend, field, field), yFinalFormatter(_.get(xPointMap, mouseX + '.y.' + field)), _.get(xPointMap, mouseX + '.y.' + field)))) : null), y2AxisToolTip)))) : null), React.createElement("g", { transform: `translate(${margin.left}, ${innerHeight + margin.top})` }, React.createElement(Axis, { orient: 'bottom', scale: xScale, outerTickSize: 0, tickFormat: xFinalFormatter, tickCount: xAxisTickCount, ticks: xAxisTicks, textOrientation: xAxisTextOrientation }), hasLegend ? (React.createElement(ContextMenu, { direction: 'down', alignment: 'center', directonOffset: (margin.bottom / 2 + Legend.HEIGHT / 2) * -1 /* should center the legend in the bottom margin */ }, React.createElement(ContextMenu.Target, { elementType: 'g' }, React.createElement("rect", { className: cx('&-invisible'), width: innerWidth, height: margin.bottom })), React.createElement(ContextMenu.FlyOut, { className: cx('&-legend-container') }, React.createElement(Legend, { orient: 'horizontal' }, _.map(yAxisFields, (field, index) => (React.createElement(Legend.Item, { key: index, hasPoint: yAxisHasPointsFinal, hasLine: yAxisHasLinesFinal, color: _.get(colorMap, field, palette[index + (yAxisColorOffset % palette.length)]), pointKind: yAxisHasPoints ? index + yAxisColorOffset : 1 }, _.get(legend, field, field)))), y2AxisLegend)))) : null), xAxisTitle ? (React.createElement("g", { transform: `translate(${margin.left}, ${margin.top + innerHeight})` }, React.createElement(AxisLabel, { orient: 'bottom', width: innerWidth, height: margin.bottom, label: xAxisTitle, color: _.isString(xAxisTitleColor) ? xAxisTitleColor : palette[xAxisTitleColor % palette.length] }))) : null, React.createElement("g", { transform: `translate(${margin.left}, ${margin.top})` }, React.createElement(Axis, { orient: 'left', scale: yScale, tickFormat: yAxisFinalFormatter, tickCount: yAxisTickCount, textOrientation: yAxisTextOrientation })), yAxisTitle ? (React.createElement("g", { transform: `translate(0, ${margin.top})` }, React.createElement(AxisLabel, { orient: 'left', width: margin.left, height: innerHeight, label: yAxisTitle, color: _.isString(yAxisTitleColor) ? yAxisTitleColor : palette[yAxisTitleColor % palette.length] }))) : null, _.get(y2Axis, 'axis', null), _.get(y2Axis, 'title', null), React.createElement("g", { transform: `translate(${margin.left}, ${margin.top})` }, React.createElement(Lines, { xScale: xScale, yScale: yScale, xField: xAxisField, yFields: yAxisFields, yStackedMax: yAxisMax, data: data || {}, isStacked: yAxisIsStacked, colorMap: colorMap, palette: palette, colorOffset: yAxisColorOffset })), yAxisHasPoints ? (React.createElement("g", { transform: `translate(${margin.left}, ${margin.top})` }, React.createElement(Points, { xScale: xScale, yScale: yScale, xField: xAxisField, yFields: yAxisFields, yStackedMax: yAxisMax, data: data, isStacked: yAxisIsStacked, colorMap: colorMap, palette: palette, colorOffset: yAxisColorOffset }))) : null, _.get(y2Axis, 'lines', null), _.get(y2Axis, 'points', null), hasToolTips ? (React.createElement("g", { transform: `translate(${margin.left}, ${margin.top})` }, React.createElement("rect", { className: cx('&-invisible'), width: innerWidth, height: innerHeight, onMouseMove: event => { this.handleToolTipHoverZone(event, xPoints); }, onMouseOut: () => { this.setState({ isHovering: false }); } }))) : null)); } } LineChart.displayName = 'LineChart'; LineChart.peek = { description: ` The line chart presents data over time. Currently only dates are supported on the x axis and numeric values on the y. If you need discrete values on the x axis, consider using the \`BarChart\` instead. `, categories: ['visualizations', 'charts'], madeFrom: ['ContextMenu', 'ToolTip'], }; LineChart.MARGIN = { top: 10, right: 80, bottom: 65, left: 80, }; LineChart.propTypes = { className: string ` Appended to the component-specific class names set on the root element. `, height: number ` Height of the chart. `, width: number ` Width of the chart. `, margin: shape({ top: number, right: number, bottom: number, left: number, }) ` An object defining the margins of the chart. These margins will contain the axis and labels. `, data: arrayOf(object) ` Data for the chart. E.g. [ { x: new Date('2015-01-01') , y: 1 } , { x: new Date('2015-01-02') , y: 2 } , { x: new Date('2015-01-03') , y: 3 } , { x: new Date('2015-01-04') , y: 2 } , { x: new Date('2015-01-05') , y: 5 } , ] `, legend: object ` An object with human readable names for fields that will be used for legends and tooltips. E.g: { x: 'Date', y: 'Impressions', } `, isLoading: bool ` Controls the visibility of the \`LoadingMessage\`. `, hasToolTips: bool ` Show tool tips on hover. `, hasLegend: bool ` Show a legend at the bottom of the chart. `, palette: arrayOf(string) ` Takes one of the palettes exported from \`lucid.chartConstants\`. Available palettes: - \`PALETTE_7\` (default) - \`PALETTE_30\` - \`PALETTE_MONOCHROME_0_5\` - \`PALETTE_MONOCHROME_1_5\` - \`PALETTE_MONOCHROME_2_5\` - \`PALETTE_MONOCHROME_3_5\` - \`PALETTE_MONOCHROME_4_5\` - \`PALETTE_MONOCHROME_5_5\` - \`PALETTE_MONOCHROME_6_5\` `, colorMap: object ` You can pass in an object if you want to map fields to \`lucid.chartConstants\` or custom colors: { 'imps': COLOR_0, 'rev': COLOR_3, 'clicks': '#abc123', } `, xAxisField: string ` The field we should look up your x data by. The data must be valid javascript dates. `, xAxisMin: instanceOf(Date) ` The minimum date the x axis should display. Typically this will be the smallest items from your dataset. `, xAxisMax: instanceOf(Date) ` The maximum date the x axis should display. This should almost always be the largest date from your dataset. `, xAxisFormatter: func ` An optional function used to format your x axis data. If you don't provide anything, we use the default D3 date variable formatter. `, xAxisTooltipFormatter: func ` An optional function used to format your x axis dates in the tooltips. `, xAxisTickCount: number ` There are some cases where you need to only show a "sampling" of ticks on the x axis. This number will control that. `, xAxisTicks: arrayOf(instanceOf(Date)) ` In some cases xAxisTickCount is not enough and you want to specify exactly where the tick marks should appear on the x axis. This prop takes an array of dates (currently only dates are supported for the x axis). This prop will override the \`xAxisTickCount\` prop. `, xAxisTitle: string ` Set a title for the x axis. `, xAxisTitleColor: oneOfType([number, string]) ` Set a color for the x axis title. Use the color constants exported off \`lucid.chartConstants\`. E.g.: - \`COLOR_0\` - \`COLOR_GOOD\` - \`'#123abc'\` // custom color hex \`number\` is supported only for backwards compatability. `, xAxisTextOrientation: oneOf(['vertical', 'horizontal', 'diagonal']) ` Determines the orientation of the tick text. This may override what the orient prop tries to determine. `, yAxisFields: arrayOf(string) ` An array of your y axis fields. Typically this will just be a single item unless you need to display multiple lines. The order of the array determines the series order in the chart. `, yAxisMin: number ` The minimum number the y axis should display. Typically this should be \`0\`. `, yAxisMax: number ` The maximum number the y axis should display. This should almost always be the largest number from your dataset. `, yAxisFormatter: func ` An optional function used to format your y axis data. If you don't provide anything, we use the default D3 formatter. `, yAxisIsStacked: bool ` Stack the y axis data. This is only useful if you have multiple \`yAxisFields\`. Stacking will cause the chart to be aggregated by sum. `, yAxisHasPoints: bool ` Display points along with the y axis lines. `, yAxisTickCount: number ` There are some cases where you need to only show a "sampling" of ticks on the y axis. This number will control that. `, yAxisTitle: string ` Set a title for the y axis. `, yAxisTitleColor: oneOfType([number, string]) ` Set a color for the y axis title. Use the color constants exported off \`lucid.chartConstants\`. E.g.: - \`COLOR_0\` - \`COLOR_GOOD\` - \`'#123abc'\` // custom color hex \`number\` is supported only for backwards compatability. `, yAxisTooltipFormatter: func ` An optional function used to format your y axis titles and data in the tooltips. The first value is the name of your y field, the second value is your post-formatted y value, and the third value is your non-formatted y-value. Signature: \`(yField, yValueFormatted, yValue) => {}\` `, yAxisTooltipDataFormatter: func ` An optional function used to format data in the tooltips. `, yAxisColorOffset: number ` Set the starting index where colors start rotating for points and lines along the y axis. `, y2AxisFields: arrayOf(string) ` An array of your y2 axis fields. Typically this will just be a single item unless you need to display multiple lines. The order of the array determines the series order in the chart. `, y2AxisMin: number ` The minimum number the y2 axis should display. Typically this should be \`0\`. `, y2AxisMax: number ` The maximum number the y2 axis should display. This should almost always be the largest number from your dataset. `, y2AxisFormatter: func ` An optional function used to format your y2 axis data. If you don't provide anything, we use the default D3 formatter. `, y2AxisTooltipDataFormatter: func ` An optional function used to format data in the tooltips. `, y2AxisIsStacked: bool ` Stack the y2 axis data. This is only useful if you have multiple \`y2AxisFields\`. Stacking will cause the chart to be aggregated by sum. `, y2AxisHasPoints: bool ` Display points along with the y2 axis lines. `, y2AxisTickCount: number ` There are some cases where you need to only show a "sampling" of ticks on the y2 axis. This number will control that. `, y2AxisTitle: string ` Set a title for the y2 axis. `, y2AxisTitleColor: oneOfType([number, string]) ` Set a color for the y2 axis title. Use the color constants exported off \`lucid.chartConstants\`. E.g.: - \`COLOR_0\` - \`COLOR_GOOD\` - \`'#123abc'\` // custom color hex \`number\` is supported only for backwards compatability. `, y2AxisColorOffset: number ` Set the starting index where colors start rotating for points and lines along the y2 axis. `, yAxisTextOrientation: oneOf(['vertical', 'horizontal', 'diagonal']) ` Determines the orientation of the tick text. This may override what the orient prop tries to determine. `, }; LineChart.defaultProps = { height: 400, width: 1000, margin: { top: 10, right: 80, bottom: 65, left: 80, }, hasToolTips: true, hasLegend: false, palette: chartConstants.PALETTE_7, xAxisField: 'x', xAxisFormatter: formatDate, // E.g. "Mon 06/06/2016 15:46:19" xAxisTooltipFormatter: d3TimeFormat.timeFormat('%a %x %X'), xAxisTickCount: null, xAxisTicks: undefined, xAxisTitle: null, xAxisTitleColor: '#000', xAxisTextOrientation: 'horizontal', yAxisFields: ['y'], yAxisMin: 0, yAxisIsStacked: false, yAxisHasPoints: true, yAxisTickCount: null, yAxisTitle: null, yAxisTitleColor: '#000', yAxisTooltipFormatter: (yField, yValueFormatted) => `${yField}: ${yValueFormatted}`, yAxisColorOffset: 0, y2AxisFields: [], y2AxisMin: 0, y2AxisIsStacked: false, y2AxisHasPoints: true, y2AxisTickCount: null, y2AxisTitle: null, y2AxisTitleColor: '#000', y2AxisColorOffset: 1, yAxisTextOrientation: 'horizontal', }; LineChart.EmptyStateWrapper = EmptyStateWrapper; export default LineChart;