lucid-ui
Version:
A UI component library from AppNexus.
234 lines (228 loc) • 9.9 kB
JavaScript
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 (React.createElement(Legend, { hasBorders: false, isReversed: isStacked }, _.map(yFields, (field, fieldIndex) => (React.createElement(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]))))));
},
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 (React.createElement("g", Object.assign({}, omitProps(passThroughs, Bars), { className: cx(className, '&') }), _.map(transformedData, (series, seriesIndex) => (React.createElement("g", { key: seriesIndex },
_.map(series, ([start, end], pointsIndex) => (React.createElement(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]) }))),
React.createElement(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 }))))));
},
});
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 (React.createElement(ToolTip, { isExpanded: isExpanded, flyOutMaxWidth: 'none', isLight: true },
React.createElement(ToolTip.Target, { elementType: 'g' },
React.createElement("rect", { className: cx('&-tooltip-hover-zone'), height: height, width: width, x: x, y: y, onMouseEnter: this.handleMouseEnter, onMouseOut: onMouseOut })),
React.createElement(ToolTip.Title, null, xFormatter(data[seriesIndex][xField], data[seriesIndex])),
React.createElement(ToolTip.Body, null, renderBody(data[seriesIndex]))));
},
});
export default Bars;