Skip to content
Permalink
premodern
Switch branches/tags
Go to file
 
 
Cannot retrieve contributors at this time
389 lines (370 sloc) 11.1 KB
// Copyright (c) 2016 - 2017 Uber Technologies, Inc.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
import React from 'react';
import PropTypes from 'prop-types';
import {scaleLinear} from 'd3-scale';
import {format} from 'd3-format';
import {AnimationPropType} from 'animation';
import XYPlot from 'plot/xy-plot';
import {DISCRETE_COLOR_RANGE} from 'theme';
import {MarginPropType} from 'utils/chart-utils';
import {getCombinedClassName} from 'utils/styling-utils';
import MarkSeries from 'plot/series/mark-series';
import PolygonSeries from 'plot/series/polygon-series';
import LabelSeries from 'plot/series/label-series';
import DecorativeAxis from 'plot/axis/decorative-axis';
const predefinedClassName = 'rv-radar-chart';
const DEFAULT_FORMAT = format('.2r');
/**
* Generate axes for each of the domains
* @param {Object} props
- props.animation {Boolean}
- props.domains {Array} array of object specifying the way each axis is to be plotted
- props.style {object} style object for the whole chart
- props.tickFormat {Function} formatting function for axes
- props.startingAngle {number} the initial angle offset
* @return {Array} the plotted axis components
*/
function getAxes(props) {
const {
animation,
domains,
startingAngle,
style,
tickFormat,
hideInnerMostValues
} = props;
return domains.map((domain, index) => {
const angle = (index / domains.length) * Math.PI * 2 + startingAngle;
const sortedDomain = domain.domain;
const domainTickFormat = t => {
if (hideInnerMostValues && t === sortedDomain[0]) {
return '';
}
return domain.tickFormat ? domain.tickFormat(t) : tickFormat(t);
};
return (
<DecorativeAxis
animation={animation}
key={`${index}-axis`}
axisStart={{x: 0, y: 0}}
axisEnd={{
x: getCoordinate(Math.cos(angle)),
y: getCoordinate(Math.sin(angle))
}}
axisDomain={sortedDomain}
numberOfTicks={5}
tickValue={domainTickFormat}
style={style.axes}
/>
);
});
}
/**
* Generate x or y coordinate for axisEnd
* @param {Number} axisEndPoint
- epsilon is an arbitrarily chosen small number to approximate axisEndPoints
- to true values resulting from trigonometry functions (sin, cos) on angles
* @return {Number} the x or y coordinate accounting for exact trig values
*/
function getCoordinate(axisEndPoint) {
const epsilon = 10e-13;
if (Math.abs(axisEndPoint) <= epsilon) {
axisEndPoint = 0;
} else if (axisEndPoint > 0) {
if (Math.abs(axisEndPoint - 0.5) <= epsilon) {
axisEndPoint = 0.5;
}
} else if (axisEndPoint < 0) {
if (Math.abs(axisEndPoint + 0.5) <= epsilon) {
axisEndPoint = -0.5;
}
}
return axisEndPoint;
}
/**
* Generate labels for the ends of the axes
* @param {Object} props
- props.domains {Array} array of object specifying the way each axis is to be plotted
- props.startingAngle {number} the initial angle offset
- props.style {object} style object for just the labels
* @return {Array} the prepped data for the labelSeries
*/
function getLabels(props) {
const {domains, startingAngle, style} = props;
return domains.map(({name}, index) => {
const angle = (index / domains.length) * Math.PI * 2 + startingAngle;
const radius = 1.2;
return {
x: radius * Math.cos(angle),
y: radius * Math.sin(angle),
label: name,
style
};
});
}
/**
* Generate the actual polygons to be plotted
* @param {Object} props
- props.animation {Boolean}
- props.data {Array} array of object specifying what values are to be plotted
- props.domains {Array} array of object specifying the way each axis is to be plotted
- props.startingAngle {number} the initial angle offset
- props.style {object} style object for the whole chart
* @return {Array} the plotted axis components
*/
function getPolygons(props) {
const {animation, colorRange, domains, data, style, startingAngle, onSeriesMouseOver, onSeriesMouseOut} = props;
const scales = domains.reduce((acc, {domain, name}) => {
acc[name] = scaleLinear().domain(domain).range([0, 1]);
return acc;
}, {});
return data.map((row, rowIndex) => {
const mappedData = domains.map(({name, getValue}, index) => {
const dataPoint = getValue ? getValue(row) : row[name];
// error handling if point doesn't exist
const angle = (index / domains.length) * Math.PI * 2 + startingAngle;
// dont let the radius become negative
const radius = Math.max(scales[name](dataPoint), 0);
return {x: radius * Math.cos(angle), y: radius * Math.sin(angle), name: row.name};
});
return (
<PolygonSeries
animation={animation}
className={`${predefinedClassName}-polygon`}
key={`${rowIndex}-polygon`}
data={mappedData}
style={{
stroke:
row.color || row.stroke || colorRange[rowIndex % colorRange.length],
fill:
row.color || row.fill || colorRange[rowIndex % colorRange.length],
...style.polygons
}}
onSeriesMouseOver={onSeriesMouseOver}
onSeriesMouseOut={onSeriesMouseOut}
/>
);
});
}
/**
* Generate circles at the polygon points for Hover functionality
* @param {Object} props
- props.animation {Boolean}
- props.data {Array} array of object specifying what values are to be plotted
- props.domains {Array} array of object specifying the way each axis is to be plotted
- props.startingAngle {number} the initial angle offset
- props.style {object} style object for the whole chart
- props.onValueMouseOver {function} function to call on mouse over a polygon point
- props.onValueMouseOver {function} function to call when mouse leaves a polygon point
* @return {Array} the plotted axis components
*/
function getPolygonPoints(props) {
const {
animation,
domains,
data,
startingAngle,
style,
onValueMouseOver,
onValueMouseOut
} = props;
if (!onValueMouseOver) {
return;
}
const scales = domains.reduce((acc, {domain, name}) => {
acc[name] = scaleLinear()
.domain(domain)
.range([0, 1]);
return acc;
}, {});
return data.map((row, rowIndex) => {
const mappedData = domains.map(({name, getValue}, index) => {
const dataPoint = getValue ? getValue(row) : row[name];
// error handling if point doesn't exist
const angle = (index / domains.length) * Math.PI * 2 + startingAngle;
// dont let the radius become negative
const radius = Math.max(scales[name](dataPoint), 0);
return {
x: radius * Math.cos(angle),
y: radius * Math.sin(angle),
domain: name,
value: dataPoint,
dataName: row.name
};
});
return (
<MarkSeries
animation={animation}
className={`${predefinedClassName}-polygonPoint`}
key={`${rowIndex}-polygonPoint`}
data={mappedData}
size={10}
style={{
...style.polygons,
fill: 'transparent',
stroke: 'transparent'
}}
onValueMouseOver={onValueMouseOver}
onValueMouseOut={onValueMouseOut}
/>
);
});
}
function RadarChart(props) {
const {
animation,
className,
children,
colorRange,
data,
domains,
height,
hideInnerMostValues,
margin,
onMouseLeave,
onMouseEnter,
startingAngle,
style,
tickFormat,
width,
renderAxesOverPolygons,
onValueMouseOver,
onValueMouseOut,
onSeriesMouseOver,
onSeriesMouseOut
} = props;
const axes = getAxes({
domains,
animation,
hideInnerMostValues,
startingAngle,
style,
tickFormat
});
const polygons = getPolygons({
animation,
colorRange,
domains,
data,
startingAngle,
style,
onSeriesMouseOver,
onSeriesMouseOut
});
const polygonPoints = getPolygonPoints({
animation,
colorRange,
domains,
data,
startingAngle,
style,
onValueMouseOver,
onValueMouseOut
});
const labelSeries = (
<LabelSeries
animation={animation}
key={className}
className={`${predefinedClassName}-label`}
data={getLabels({domains, style: style.labels, startingAngle})} />
);
return (
<XYPlot
height={height}
width={width}
margin={margin}
dontCheckIfEmpty
className={getCombinedClassName(className, predefinedClassName)}
onMouseLeave={onMouseLeave}
onMouseEnter={onMouseEnter}
xDomain={[-1, 1]}
yDomain={[-1, 1]}>
{children}
{!renderAxesOverPolygons &&
axes
.concat(polygons)
.concat(labelSeries)
.concat(polygonPoints)}
{renderAxesOverPolygons &&
polygons
.concat(labelSeries)
.concat(axes)
.concat(polygonPoints)}
</XYPlot>
);
}
RadarChart.displayName = 'RadarChart';
RadarChart.propTypes = {
animation: AnimationPropType,
className: PropTypes.string,
colorType: PropTypes.string,
colorRange: PropTypes.arrayOf(PropTypes.string),
data: PropTypes.arrayOf(PropTypes.object).isRequired,
domains: PropTypes.arrayOf(
PropTypes.shape({
name: PropTypes.string.isRequired,
domain: PropTypes.arrayOf(PropTypes.number).isRequired,
tickFormat: PropTypes.func
})
).isRequired,
height: PropTypes.number.isRequired,
hideInnerMostValues: PropTypes.bool,
margin: MarginPropType,
startingAngle: PropTypes.number,
style: PropTypes.shape({
axes: PropTypes.object,
labels: PropTypes.object,
polygons: PropTypes.object
}),
tickFormat: PropTypes.func,
width: PropTypes.number.isRequired,
renderAxesOverPolygons: PropTypes.bool,
onValueMouseOver: PropTypes.func,
onValueMouseOut: PropTypes.func,
onSeriesMouseOver: PropTypes.func,
onSeriesMouseOut: PropTypes.func
};
RadarChart.defaultProps = {
className: '',
colorType: 'category',
colorRange: DISCRETE_COLOR_RANGE,
hideInnerMostValues: true,
startingAngle: Math.PI / 2,
style: {
axes: {
line: {},
ticks: {},
text: {}
},
labels: {
fontSize: 10,
textAnchor: 'middle'
},
polygons: {
strokeWidth: 0.5,
strokeOpacity: 1,
fillOpacity: 0.1
}
},
tickFormat: DEFAULT_FORMAT,
renderAxesOverPolygons: false
};
export default RadarChart;