lucid-ui
Version:
A UI component library from AppNexus.
390 lines (339 loc) • 9.55 kB
JSX
import _ from 'lodash';
import React from 'react';
import PropTypes from 'react-peek/prop-types';
import { lucidClassNames } from '../../util/style-helpers';
import { extractFields, stackByFields } from '../../util/chart-helpers';
import { createClass, omitProps } from '../../util/component-types';
import * as d3Scale from 'd3-scale';
import * as chartConstants from '../../constants/charts';
import shallowCompare from 'react-addons-shallow-compare';
import Bar from '../Bar/Bar';
import { ToolTipDumb as ToolTip } from '../ToolTip/ToolTip';
import Legend from '../Legend/Legend';
// memoizing to maintain referential equality across renders, for performance
// optimization with shallow comparison
const memoizedExtractFields = _.memoize(extractFields);
const memoizedStackByFields = _.memoize(stackByFields);
const cx = lucidClassNames.bind('&-Bars');
const { arrayOf, func, number, object, bool, string } = PropTypes;
const Bars = createClass({
displayName: 'Bars',
statics: {
peek: {
description: `
*For use within an \`svg\`*
Bars are typically used to represent categorical data and can be
stacked or grouped.
`,
categories: ['visualizations', 'chart primitives'],
madeFrom: ['Bar', 'ToolTip', 'Legend'],
},
},
propTypes: {
className: string`
Appended to the component-specific class names set on the root element.
`,
data: arrayOf(object).isRequired`
De-normalized data
[
{ x: 'one', y0: 1, y1: 2, y2: 3, y3: 5 },
{ x: 'two', y0: 2, y1: 3, y2: 4, y3: 6 },
{ x: 'three', y0: 2, y1: 4, y2: 5, y3: 6 },
{ x: 'four', y0: 3, y1: 6, y2: 7, y3: 7 },
{ x: 'five', y0: 4, y1: 8, y2: 9, y3: 8 },
]
`,
legend: object`
An object with human readable names for fields that will be used for
tooltips. E.g:
{
rev: 'Revenue',
imps: 'Impressions',
}
`,
hasToolTips: bool`
Show tool tips on hover.
`,
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',
}
`,
xScale: func.isRequired`
The scale for the x axis. Must be a d3 band scale. Lucid exposes the
\`lucid.d3Scale.scaleBand\` library for use here.
`,
xField: string`
The field we should look up your x data by.
`,
xFormatter: func`
Function to format the x data.
`,
yScale: func.isRequired`
The scale for the y axis. Must be a d3 scale. Lucid exposes the
\`lucid.d3Scale\` library for use here.
`,
yFields: arrayOf(string)`
The field(s) we should look up your y data by. Each entry represents a
series. Your actual y data should be numeric.
`,
yFormatter: func`
Function to format the y data.
`,
yStackedMax: number`
Typically this number can be derived from the yScale. However when we're
\`isStacked\` we need to calculate a new domain for the yScale based on
the sum of the data. If you need explicit control of the y max when
stacking, pass it in here.
`,
yTooltipFormatter: 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) => {}\`
`,
isStacked: bool`
This will stack the data instead of grouping it. In order to stack the
data we have to calculate a new domain for the y scale that is based on
the \`sum\` of the data.
`,
colorOffset: number`
Sometimes you might not want the colors to start rotating at the blue
color, this number will be added the bar index in determining which color
the bars are.
`,
renderTooltipBody: func`
An optional function used to format the entire tooltip body. The only arg is
the associated data point. This formatter will over-ride yAxisTooltipFormatter
and yAxisTooltipDataFormatter. Signature:
\`dataPoint => {}\`
`,
},
defaultTooltipFormatter(dataPoint) {
const {
colorMap,
colorOffset,
isStacked,
legend,
palette,
yFields,
yFormatter,
yTooltipFormatter,
} = this.props;
return (
<Legend hasBorders={false} isReversed={isStacked}>
{_.map(yFields, (field, fieldIndex) => (
<Legend.Item
key={fieldIndex}
hasPoint={true}
pointKind={1}
color={_.get(
colorMap,
field,
palette[(fieldIndex + colorOffset) % palette.length]
)}
>
{yTooltipFormatter(
_.get(legend, field, field),
yFormatter(dataPoint[field], dataPoint),
dataPoint[field]
)}
</Legend.Item>
))}
</Legend>
);
},
handleMouseEnter(hoveringSeriesIndex) {
this.setState({
hoveringSeriesIndex,
});
},
handleMouseOut() {
this.setState({ hoveringSeriesIndex: null });
},
shouldComponentUpdate(...args) {
return shallowCompare(this, ...args);
},
getDefaultProps() {
return {
hasToolTips: true,
xField: 'x',
xFormatter: _.identity,
yFields: ['y'],
yFormatter: _.identity,
yTooltipFormatter: (yField, yValueFormatted) =>
`${yField}: ${yValueFormatted}`,
renderTooltipBody: null,
isStacked: false,
colorOffset: 0,
palette: chartConstants.PALETTE_7,
};
},
getInitialState() {
return {
hoveringSeriesIndex: null,
};
},
render() {
const {
className,
data,
hasToolTips,
palette,
colorMap,
colorOffset,
xScale,
xField,
xFormatter,
yScale: yScaleOriginal,
yFields,
yStackedMax,
renderTooltipBody,
isStacked,
...passThroughs
} = this.props;
const { hoveringSeriesIndex } = this.state;
// This scale is used for grouped bars
const innerXScale = d3Scale
.scaleBand()
.domain(_.times(yFields.length))
.range([0, xScale.bandwidth()])
.round(true);
// Copy the original so we can mutate it
const yScale = yScaleOriginal.copy();
// If we are stacked, we need to calculate a new domain based on the sum of
// the various series' y data. One row per series.
const transformedData = isStacked
? memoizedStackByFields(data, yFields)
: memoizedExtractFields(data, yFields);
// If we are stacked, we need to calculate a new domain based on the sum of
// the various group's y data
if (isStacked) {
yScale.domain([
yScale.domain()[0],
yStackedMax || _.max(_.map(transformedData, (x) => _.last(_.last(x)))),
]);
}
return (
<g {...omitProps(passThroughs, Bars)} className={cx(className, '&')}>
{_.map(transformedData, (series, seriesIndex) => (
<g key={seriesIndex}>
{_.map(series, ([start, end], pointsIndex) => (
<Bar
key={pointsIndex}
x={
isStacked
? xScale(data[seriesIndex][xField])
: innerXScale(pointsIndex) +
xScale(data[seriesIndex][xField])
}
y={yScale(end)}
height={yScale(start) - yScale(end)}
width={isStacked ? xScale.bandwidth() : innerXScale.bandwidth()}
color={_.get(
colorMap,
yFields[pointsIndex],
palette[(pointsIndex + colorOffset) % palette.length]
)}
/>
))}
<PureToolTip
isExpanded={hasToolTips && hoveringSeriesIndex === seriesIndex}
height={
isStacked
? yScale.range()[0] - yScale(_.last(series)[1])
: yScale.range()[0] - yScale(_.max(_.flatten(series)))
}
width={xScale.bandwidth()}
x={xScale(data[seriesIndex][xField])}
y={yScale(_.max(_.flatten(series)))}
series={series}
seriesIndex={seriesIndex}
onMouseEnter={this.handleMouseEnter}
onMouseOut={this.handleMouseOut}
xFormatter={xFormatter}
xField={xField}
renderBody={renderTooltipBody || this.defaultTooltipFormatter}
data={data}
/>
</g>
))}
</g>
);
},
});
export const PureToolTip = createClass({
_isPrivate: true,
propTypes: {
data: arrayOf(object),
height: number,
isExpanded: bool,
onMouseEnter: func,
onMouseOut: func,
renderBody: func,
seriesIndex: number,
width: number,
x: number,
xField: string,
xFormatter: func,
y: number,
},
shouldComponentUpdate(...args) {
return shallowCompare(this, ...args);
},
handleMouseEnter() {
this.props.onMouseEnter(this.props.seriesIndex);
},
render() {
const {
isExpanded,
height,
width,
x,
y,
seriesIndex,
onMouseOut,
renderBody,
data,
xFormatter,
xField,
} = this.props;
return (
<ToolTip isExpanded={isExpanded} flyOutMaxWidth='none' isLight={true}>
<ToolTip.Target elementType='g'>
<rect
className={cx('&-tooltip-hover-zone')}
height={height}
width={width}
x={x}
y={y}
onMouseEnter={this.handleMouseEnter}
onMouseOut={onMouseOut}
/>
</ToolTip.Target>
<ToolTip.Title>
{xFormatter(data[seriesIndex][xField], data[seriesIndex])}
</ToolTip.Title>
<ToolTip.Body>{renderBody(data[seriesIndex])}</ToolTip.Body>
</ToolTip>
);
},
});
export default Bars;