UNPKG

recharts

Version:
815 lines (716 loc) 24.1 kB
import React, { PropTypes } from 'react'; import ReactDOM from 'react-dom'; import { getNiceTickValues } from 'recharts-scale'; import D3Scale from 'd3-scale'; import Layer from '../container/Layer'; import CartesianAxis from '../component/CartesianAxis'; import CartesianGrid from '../component/CartesianGrid'; import ReactUtils from '../util/ReactUtils'; import DOMUtils from '../util/DOMUtils'; import LodashUtils from '../util/LodashUtils'; import XAxis from './XAxis'; import YAxis from './YAxis'; import Tooltip from '../component/Tooltip'; import Brush from '../component/Brush'; import ReferenceLine from '../component/ReferenceLine'; const ORIENT_MAP = { xAxis: ['bottom', 'top'], yAxis: ['left', 'right'], }; /** * The base class of chart in cartesian coordinate system */ class CartesianChart extends React.Component { static propTypes = { width: PropTypes.number.isRequired, height: PropTypes.number.isRequired, data: PropTypes.arrayOf(PropTypes.object), layout: PropTypes.oneOf(['horizontal', 'vertical']), margin: PropTypes.shape({ top: PropTypes.number, right: PropTypes.number, bottom: PropTypes.number, left: PropTypes.number, }), stackType: PropTypes.oneOf(['value', 'percent']), title: PropTypes.string, style: PropTypes.object, barOffset: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), barGap: PropTypes.number, barSize: PropTypes.number, children: PropTypes.oneOfType([ PropTypes.arrayOf(PropTypes.node), PropTypes.node, ]), }; static defaultProps = { style: {}, barOffset: '10%', barGap: 4, layout: 'horizontal', margin: { top: 5, right: 5, bottom: 5, left: 5 }, }; constructor(props) { super(props); this.state = { dataStartIndex: 0, dataEndIndex: props.data.length - 1, activeTooltipIndex: -1, activeTooltipLabel: '', activeTooltipPosition: 'left-bottom', activeTooltipCoord: { x: 0, y: 0 }, isTooltipActive: false, activeLineKey: null, activeBarKey: null, }; } /** * get coordinate of cursor in chart * @param {Object} e event object * @param {Object} containerOffset offser of chart container * @return {Object} {chartX, chartY} */ getChartPosition(e, containerOffset) { return { chartX: Math.round(e.pageX - containerOffset.left), chartY: Math.round(e.pageY - containerOffset.top), }; } /** * get domain of ticks * @param {Array} ticks Ticks of axis * @param {String} type The type of axis * @return {Array} domain */ getDomainOfTicks(ticks, type) { if (type === 'number') { return [Math.min.apply(null, ticks), Math.max.apply(null, ticks)]; } return ticks; } /** * 根据指标名称获取 定义域 * @param {String} key 数据的key值 * @param {String} type 刻度类型 * @return {Array} 定义域 */ getDomainByKey(key, type) { const { data } = this.props; const { dataStartIndex, dataEndIndex } = this.state; const domain = data.slice(dataStartIndex, dataEndIndex + 1) .map(entry => entry[key]); return type === 'number' ? [ Math.min.apply(null, domain), Math.max.apply(null, domain), ] : domain; } /** * 根据LineItem或者Bar计算数据的定义域 * @param {Array} items 线图元素或者柱图元素 * @param {String} type 相应的坐标轴类型,number - 数值类型的坐标轴, category - 类目轴 * @return {Array} 取值范围 */ getDomainOfItems(items, type) { const domain = items.map(item => { return this.getDomainByKey(item.props.dataKey, type); }); if (type === 'number') { // 计算数值类型的轴的取值范围 return domain.reduce((result, entry) => { return [ Math.min(result[0], entry[0]), Math.max(result[1], entry[1]), ]; }, [Infinity, -Infinity]); } const tag = {}; const result = []; // 类目轴,计算类目轴的并集 domain.forEach(entry => { for (let i = 0, len = entry.length; i < len; i++) { if (!tag[entry[i]]) { tag[entry[i]] = true; result.push(entry[i]); } } }); return result; } /** * 计算X轴 或者 Y轴的配置 * @param {String} axisType 轴的类型 * @param {Array} items 图形元素 * @return {Object} 轴的配置 */ getAxisMap(axisType = 'xAxis', items) { const { children } = this.props; const Axis = axisType === 'xAxis' ? XAxis : YAxis; const axisIdKey = axisType === 'xAxis' ? 'xAxisId' : 'yAxisId'; // 所有定义的XAxis组件 const axes = ReactUtils.findAllByType(children, Axis); let axisMap = {}; // 根据用户显性配置的轴来计算轴的配置 if (axes && axes.length) { axisMap = this.getAxisMapByAxes(axes, items, axisType, axisIdKey); } else if (items && items.length) { axisMap = this.getAxisMapByItems(items, Axis, axisType, axisIdKey); } return axisMap; } /** * 根据用户显性配置的轴来计算轴的配置 * @param {Array} axes 轴对象 * @param {Array} items 线图元素或者柱图元素 * @param {String} axisType 轴的类型, xAxis - x轴, yAxis - y轴 * @param {String} axisIdKey 标识轴的id的key * @return {Object} 刻度配置对象 */ getAxisMapByAxes(axes, items, axisType, axisIdKey) { // 需要去除重复的情况 const axisMap = axes.reduce((result, child) => { const { type, dataKey } = child.props; const axisId = child.props[axisIdKey]; if (!result[axisId]) { let domain; if (child.props.data) { domain = child.props.data; } else if (dataKey) { domain = this.getDomainByKey(dataKey, type); } else { domain = this.getDomainOfItems(items.filter(entry => { return entry.props[axisIdKey] === axisId; }), type); } return { ...result, [axisId]: { ...child.props, axisType, domain, } }; } return result; }, {}); return axisMap; } /** * 根据用户配置的图形元素来计算轴的配置,但是这种轴不会显示 * @param {Array} items 线图元素或者柱图元素 * @param {ReactElement} Axis 轴对象 * @param {String} axisType 轴的类型, xAxis - x轴, yAxis - y轴 * @param {String} axisIdKey 标识轴的id的key * @return {Object} 刻度配置对象 */ getAxisMapByItems(items, Axis, axisType, axisIdKey) { const { dataEndIndex, dataStartIndex } = this.state; const len = dataEndIndex - dataStartIndex + 1; let index = -1; // 当没有创建XAxis时,默认X轴为类目轴,根据data的序号来决定X轴绘制内容 const axisMap = items.reduce((result, child) => { const axisId = child.props[axisIdKey]; if (!result[axisId]) { index++; return { ...result, [axisId]: { axisType, type: Axis.defaultProps.type, hide: true, width: Axis.defaultProps.width, height: Axis.defaultProps.height, tickCount: Axis.defaultProps.tickCount, orient: ORIENT_MAP[axisType][index % 2], domain: axisType === 'xAxis' ? LodashUtils.range(0, len) : this.getDomainOfItems( items.filter(entry => entry.props[axisIdKey] === axisId), 'number' ), }, }; } return result; }, {}); return axisMap; } /** * 计算图表中图形区域的偏移量 * @param {Object} xAxisMap x轴刻度 * @param {Object} yAxisMap y轴刻度 * @return {Object} 偏移量 */ getOffset(xAxisMap, yAxisMap) { const { width, height, margin, children } = this.props; const brushItem = ReactUtils.findChildByType(children, Brush); const offsetH = Object.keys(yAxisMap).reduce((result, id) => { const entry = yAxisMap[id]; const orient = entry.orient; result[orient] += entry.hide ? 0 : entry.width; return result; }, { left: margin.left, right: margin.right }); const offsetV = Object.keys(xAxisMap).reduce((result, id) => { const entry = xAxisMap[id]; const orient = entry.orient; result[orient] += entry.hide ? 0 : entry.height; return result; }, { top: margin.top, bottom: margin.bottom }); if (brushItem) { offsetV.bottom += brushItem.props.height || Brush.defaultProps.height; } return { ...offsetH, ...offsetV, width: width - offsetH.left - offsetH.right, height: height - offsetV.top - offsetV.bottom, }; } /** * 获取图形元素的主色调 * @param {ReactElement} item 图形元素 * @return {String} 颜色 */ getMainColorOfItem(item) { const displayName = item.type.displayName; let result; switch (displayName) { case 'LineItem': result = item.props.stroke; break; default: result = item.props.fill; break; } return result; } /** * 设置刻度函数的刻度值 * @param {Object} scale 刻度对象 * @param {Object} opts 配置 * @return {Object} 无返回 */ setTicksOfScale(scale, opts) { // 优先使用用户配置的刻度 if (opts.ticks && opts.ticks) { scale.domain(this.getDomainOfTicks(opts.ticks, opts.type)) .ticks(opts.ticks.length); return; } // 数值类型的刻度,指定了刻度的个数后,根据范围动态计算 if (opts.tickCount && opts.type === 'number' && !opts.hide) { const domain = scale.domain(); const tickValues = getNiceTickValues(domain, opts.tickCount); opts.ticks = tickValues; scale.domain(this.getDomainOfTicks(tickValues, opts.type)) .ticks(opts.tickCount); } else if (opts.type === 'number' && opts.hide && scale.domain()[0] > 0) { scale.domain([0, scale.domain()[1]]); } } /** * 计算轴的刻度函数,位置,大小等信息 * @param {Object} axisMap 刻度对象 * @param {Object} offset 图形区域的偏移量 * @param {Object} axisType 刻度类型,x轴或者y轴 * @return {Object} 格式化后的刻度对象 */ getFormatAxisMap(axisMap, offset, axisType) { const { width, height, layout } = this.props; const ids = Object.keys(axisMap); const steps = { left: offset.left, right: width - offset.right, top: offset.top, bottom: height - offset.bottom, }; return ids.reduce((result, id) => { const axis = axisMap[id]; const { orient, type, domain, tickFormatter } = axis; let range; if (axisType === 'xAxis') { range = [offset.left, offset.left + offset.width]; } else { range = layout === 'horizontal' ? [offset.top + offset.height, offset.top] : [offset.top, offset.top + offset.height]; } let scale; // 数值类型的刻度使用 linear刻度,类目轴使用 ordinal刻度 if (type === 'number') { scale = D3Scale.linear().domain(domain).range(range); } else if (this.displayName === 'LineChart' || this.displayName === 'AreaChart') { scale = D3Scale.point().domain(domain).range(range); } else { scale = D3Scale.band().domain(domain).range(range); } this.setTicksOfScale(scale, axis); if (tickFormatter) { scale.tickFormat(tickFormatter); } let x; let y; if (axisType === 'xAxis') { x = offset.left; y = orient === 'top' ? steps[orient] - axis.height : steps[orient]; } else { x = orient === 'left' ? steps[orient] - axis.width : steps[orient]; y = offset.top; } result[id] = { ...axis, x, y, scale, width: axisType === 'xAxis' ? offset.width : axis.width, height: axisType === 'yAxis' ? offset.height : axis.height, }; if (!axis.hide && axisType === 'xAxis') { steps[orient] += (orient === 'top' ? -1 : 1) * result[id].height; } else if (!axis.hide) { steps[orient] += (orient === 'left' ? -1 : 1) * result[id].width; } return result; }, {}); } /** * 计算x轴,y轴的刻度 * @param {Object} axis 刻度对象 * @return {Array} 轴的刻度 */ getAxisTicks(axis, isGrid = false) { const scale = axis.scale; const offset = isGrid && axis.type === 'category' ? scale.bandwidth() / 2 : 0; if (axis.ticks) { return axis.ticks.map(entry => { return { coord: scale(entry) + offset, value: entry }; }); } if (scale.ticks) { return scale.ticks(axis.tickCount).map(entry => { return { coord: scale(entry) + offset, value: entry }; }); } return scale.domain().map((entry) => { return { coord: scale(entry) + offset, value: entry }; }); } /** * 计算网格的刻度 * @param {Array} ticks 刻度对象 * @param {Number} min 最小值 * @param {Number} max 最大值 * @return {Array} 网格的刻度 */ getGridTicks(ticks, min, max) { let hasMin; let hasMax; let values; values = ticks.map(entry => { if (entry.coord === min) { hasMin = true;} if (entry.coord === max) { hasMax = true;} return entry.coord; }); if (!hasMin) { values.push(min);} if (!hasMax) { values.push(max);} return values; } /** * 判断鼠标是否在图表的图形区域 * @param {Object} xAxisMap x轴刻度 * @param {Object} yAxisMap y轴刻度 * @param {Object} offset 图形元素的偏移量 * @param {Object} e 事件对象 * @return {Object} 格式化后的鼠标数据 */ getMouseInfo(xAxisMap, yAxisMap, offset, e) { const isIn = e.chartX >= offset.left && e.chartX <= offset.left + offset.width && e.chartY >= offset.top && e.chartY <= offset.top + offset.height; if (!isIn) {return null;} const { layout } = this.props; const axisMap = layout === 'horizontal' ? xAxisMap : yAxisMap; const pos = layout === 'horizontal' ? e.chartX : e.chartY; const ids = Object.keys(axisMap); const axis = axisMap[ids[0]]; const ticks = this.getAxisTicks(axis); let index = 0; for (let i = 0, len = ticks.length; i < len; i++) { if ((i === 0 && pos <= (ticks[i].coord + ticks[i + 1].coord) / 2) || (i > 0 && i < len - 1 && pos > (ticks[i].coord + ticks[i - 1].coord) / 2 && pos <= (ticks[i].coord + ticks[i + 1].coord) / 2) || (i === len - 1 && pos > (ticks[i].coord + ticks[i - 1].coord) / 2)) { index = i; break; } } return { activeTooltipIndex: index, activeTooltipLabel: ticks[index].value, activeTooltipPosition: (e.chartX > offset.left + offset.width / 2 ? 'right' : 'left') + '-' + (e.chartY > offset.top + offset.height / 2 ? 'bottom' : 'top'), activeTooltipCoord: { x: layout === 'horizontal' ? ticks[index].coord : e.chartX, y: layout === 'horizontal' ? e.chartY : ticks[index].coord, }, }; } /** * 更具图形元素计算tooltip的显示内容 * @param {Array} items 线图元素或者柱图元素 * @return {Array} 浮窗现实的内容 */ getTooltipContent(items) { const { activeLineKey, activeTooltipIndex, dataStartIndex, dataEndIndex } = this.state; const data = this.props.data.slice(dataStartIndex, dataEndIndex + 1); if (activeTooltipIndex < 0 || !items || !items.length) { return null; } const activeItems = activeLineKey ? items.filter(item => item.props.dataKey === activeLineKey) : items; return activeItems.map((child) => { const { dataKey, name, unit, formatter } = child.props; return { key: name || dataKey, unit: unit || '', color: this.getMainColorOfItem(child), value: data[activeTooltipIndex][dataKey], formatter, }; }); } handleBrushChange({ startIndex, endIndex }) { this.setState({ dataStartIndex: startIndex, dataEndIndex: endIndex, }); } /** * 鼠标进入图形区域 * @param {Object} offset 图形区域距离容器的偏移量 * @param {Object} xAxisMap x轴刻度 * @param {Object} yAxisMap y轴刻度 * @param {Object} e 事件对象 * @return {Null} no return */ handleMouseEnter(offset, xAxisMap, yAxisMap, e) { const container = ReactDOM.findDOMNode(this); const containerOffset = DOMUtils.offset(container); const ne = this.getChartPosition(e, containerOffset); const mouse = this.getMouseInfo(xAxisMap, yAxisMap, offset, ne); if (mouse) { this.setState({ ...mouse, isTooltipActive: true, chartX: ne.chartX, chartY: ne.chartY, }); } } /** * 鼠标在图形区域移动 * @param {Object} offset 图形区域距离容器的偏移量 * @param {Object} xAxisMap x轴刻度 * @param {Object} yAxisMap y轴刻度 * @param {Object} e 事件对象 * @return {Null} no return */ handleMouseMove(offset, xAxisMap, yAxisMap, e) { const container = ReactDOM.findDOMNode(this); const containerOffset = DOMUtils.offset(container); const ne = this.getChartPosition(e, containerOffset); const mouse = this.getMouseInfo(xAxisMap, yAxisMap, offset, ne); if (mouse) { this.setState({ ...mouse, isTooltipActive: true, chartX: ne.chartX, chartY: ne.chartY, }); } else { this.setState({ isTooltipActive: false, }); } } /** * 鼠标离开图形区域的响应事件 * @return {Null} no return */ handleMouseLeave() { this.setState({ isTooltipActive: false, }); } /** * 渲染x轴部分 * @param {Object} xAxisMap x轴刻度 * @return {ReactElement} x轴 */ renderXAxis(xAxisMap) { const { width, height } = this.props; const ids = xAxisMap && Object.keys(xAxisMap); if (ids && ids.length) { const xAxes = []; for (let i = 0, len = ids.length; i < len; i++) { const axis = xAxisMap[ids[i]]; if (!axis.hide) { xAxes.push(( <CartesianAxis {...axis} key={'x-axis-' + ids[i]} x={axis.x} y={axis.y} width={axis.width} height={axis.height} key={'x-axis-' + ids[i]} orient={axis.orient} viewBox={{ x: 0, y: 0, width, height }} ticks={this.getAxisTicks(axis, true)} /> )); } } return xAxes.length ? <Layer key="x-axis-layer" className="x-axis-layer">{xAxes}</Layer> : null; } } /** * 渲染Y轴部分 * @param {Object} yAxisMap 所有的Y轴配置 * @return {ReactElement} y轴 */ renderYAxis(yAxisMap) { const { width, height } = this.props; const ids = yAxisMap && Object.keys(yAxisMap); if (ids && ids.length) { const yAxes = []; for (let i = 0, len = ids.length; i < len; i++) { const axis = yAxisMap[ids[i]]; if (!axis.hide) { yAxes.push(( <CartesianAxis {...axis} key={'y-axis-' + ids[i]} x={axis.x} y={axis.y} width={axis.width} height={axis.height} key={'y-axis-' + ids[i]} orient={axis.orient} viewBox={{ x: 0, y: 0, width, height }} ticks={this.getAxisTicks(axis, true)} /> )); } } return yAxes.length ? <Layer key="y-axis-layer" className="y-axis-layer">{yAxes}</Layer> : null; } } /** * 渲染网格部分 * @param {Object} xAxisMap x轴刻度 * @param {Object} yAxisMap y轴刻度 * @param {Object} offset 图形区域的偏移量 * @return {ReactElement} 网格 */ renderGrid(xAxisMap, yAxisMap, offset) { const { children, width, height } = this.props; const gridItem = ReactUtils.findChildByType(children, CartesianGrid); if (!gridItem) {return null;} const xIds = Object.keys(xAxisMap); const yIds = Object.keys(yAxisMap); const xAxis = xAxisMap[xIds[0]]; const yAxis = yAxisMap[yIds[0]]; const verticalPoints = this.getGridTicks(CartesianAxis.getTicks({ ...CartesianAxis.defaultProps, ...xAxis, ticks: this.getAxisTicks(xAxis, true), viewBox: { x: 0, y: 0, width, height }, }), offset.left, offset.left + offset.width); const horizontalPoints = this.getGridTicks(CartesianAxis.getTicks({ ...CartesianAxis.defaultProps, ...yAxis, ticks: this.getAxisTicks(yAxis, true), viewBox: { x: 0, y: 0, width, height }, }), offset.top, offset.top + offset.height); return React.cloneElement(gridItem, { key: 'grid', x: offset.left, y: offset.top, width: offset.width, height: offset.height, verticalPoints, horizontalPoints, }); } /** * 绘制图例部分 * @param {Array} items 线图元素或者柱图元素 * @param {Object} offset 图形区域的偏移量 * @param {ReactElement} legendItem 图例元素 * @return {ReactElement} 图例 */ renderLegend(items, offset, legendItem) { const legendData = items.map((child) => { const { dataKey, name, legendType } = child.props; return { type: legendType || 'square', color: this.getMainColorOfItem(child), value: name || dataKey, }; }, this); return React.cloneElement(legendItem, { width: offset.width, data: legendData, }); } /** * 渲染浮层 * @param {Array} items 线图元素或者柱图元素 * @param {Object} offset 图形区域的偏移量 * @return {ReactElement} 浮层元素 */ renderTooltip(items, offset) { const { children } = this.props; const tooltipItem = ReactUtils.findChildByType(children, Tooltip); if (!tooltipItem) { return null; } const { chartX, chartY, isTooltipActive, activeTooltipLabel, activeTooltipCoord, activeTooltipPosition } = this.state; const scope = { x1: offset.left, x2: offset.left + offset.width, y1: offset.top, y2: offset.top + offset.height, }; return React.cloneElement(tooltipItem, { scope, position: activeTooltipPosition, active: isTooltipActive, label: activeTooltipLabel, data: isTooltipActive ? this.getTooltipContent(items) : [], coordinate: activeTooltipCoord, mouseX: chartX, mouseY: chartY, }); } renderBrush(xAxisMap, yAxisMap, offset) { const { children, data } = this.props; const brushItem = ReactUtils.findChildByType(children, Brush); if (!brushItem) {return null;} const dataKey = brushItem.props.dataKey; const height = (brushItem.props.height || Brush.defaultProps.height) + 1; return React.cloneElement(brushItem, { onBrushChange: ::this.handleBrushChange, data: data.map(entry => entry[dataKey]), x: offset.left, y: offset.top + offset.height + offset.bottom - height, width: offset.width, }); } renderReferenceLines(xAxisMap, yAxisMap, offset) { const { children } = this.props; const lines = ReactUtils.findAllByType(children, ReferenceLine); if (!lines || !lines.length) {return null;} return lines.map((entry, i) => { return React.cloneElement(entry, { key: 'reference-line-' + i, xAxisMap, yAxisMap, x: offset.left, y: offset.top, width: offset.width, height: offset.height, }); }); } } export default CartesianChart;