/** * @fileOverview Render sectors of a funnel */ import React, { PureComponent, ReactElement } from 'react'; import Animate from 'react-smooth'; import classNames from 'classnames'; import _ from 'lodash'; import Layer from '../container/Layer'; import Trapezoid, { Props as TrapezoidProps } from '../shape/Trapezoid'; import LabelList from '../component/LabelList'; import Cell, { Props as CellProps } from '../component/Cell'; import { findAllByType } from '../util/ReactUtils'; import Global from '../util/Global'; import { interpolateNumber } from '../util/DataUtils'; import { getValueByDataKey } from '../util/ChartUtils'; import { LegendType, TooltipType, AnimationTiming, ChartOffset, PresentationAttributes, DataKey, filterProps, adaptEventsOfChild, } from '../util/types'; interface FunnelTrapezoidItem extends TrapezoidProps { value?: number | string; payload?: any; } interface InternalFunnelProps { trapezoids?: FunnelTrapezoidItem[]; animationId?: number; } interface FunnelProps extends InternalFunnelProps { className?: string; dataKey: DataKey; nameKey?: DataKey; data?: any[]; hide?: boolean; activeShape: ReactElement | ((props: any) => SVGElement) | TrapezoidProps; legendType?: LegendType; tooltipType?: TooltipType; activeIndex?: number | number[]; onAnimationStart?: () => void; onAnimationEnd?: () => void; isAnimationActive?: boolean; animateNewValues?: boolean; animationBegin?: number; animationDuration?: number; animationEasing?: AnimationTiming; id?: string; } type Props = TrapezoidProps & FunnelProps; interface State { readonly prevTrapezoids?: FunnelTrapezoidItem[]; readonly isAnimationFinished?: boolean; } class Funnel extends PureComponent { static displayName = 'Funnel'; static defaultProps = { stroke: '#fff', fill: '#808080', legendType: 'rect', labelLine: true, hide: false, isAnimationActive: !Global.isSsr, animationBegin: 400, animationDuration: 1500, animationEasing: 'ease', nameKey: 'name', }; static getRealFunnelData = (item: Funnel) => { const { data, children } = item.props; const presentationProps = filterProps(item.props); const cells = findAllByType(children, Cell.displayName); if (data && data.length) { return data.map((entry, index) => ({ payload: entry, ...presentationProps, ...entry, ...(cells && cells[index] && cells[index].props), })); } if (cells && cells.length) { return cells.map((cell: ReactElement) => ({ ...presentationProps, ...cell.props })); } return []; }; static getRealWidthHeight = (item: Funnel, offset: ChartOffset) => { const customWidth = item.props.width; const { width, height, left, right, top, bottom } = offset; const realHeight = height; let realWidth = width; if (_.isNumber(customWidth)) { realWidth = customWidth; } else if (_.isString(customWidth)) { realWidth = (realWidth * parseFloat(customWidth)) / 100; } return { realWidth: realWidth - left - right - 50, realHeight: realHeight - bottom - top, offsetX: (width - realWidth) / 2, offsetY: (height - realHeight) / 2, }; }; static getComposedData = ({ item, offset, onItemMouseLeave, onItemMouseEnter, onItemClick, }: { item: Funnel; offset: ChartOffset; onItemMouseLeave?: PresentationAttributes['onMouseLeave']; onItemMouseEnter?: PresentationAttributes['onMouseEnter']; onItemClick?: PresentationAttributes['onClick']; }) => { const funnelData = Funnel.getRealFunnelData(item); const { dataKey, nameKey, tooltipType } = item.props; const { left, top } = offset; const { realHeight, realWidth, offsetX, offsetY } = Funnel.getRealWidthHeight(item, offset); const maxValue = getValueByDataKey(funnelData[0], dataKey, 0); const len = funnelData.length; const rowHeight = realHeight / len; const trapezoids = funnelData.map((entry: any, i: number) => { const val = getValueByDataKey(entry, dataKey, 0); const name = getValueByDataKey(entry, nameKey, i); let nextVal = 0; if (i !== len - 1) { nextVal = getValueByDataKey(funnelData[i + 1], dataKey, 0); } const x = ((maxValue - val) * realWidth) / (2 * maxValue) + top + 25 + offsetX; const y = (realHeight / len) * i + left + offsetY; const upperWidth = (val / maxValue) * realWidth; const lowerWidth = (nextVal / maxValue) * realWidth; const tooltipPayload = [{ name, value: val, payload: entry, dataKey, type: tooltipType }]; const tooltipPosition = { x: x + upperWidth / 2, y: y + rowHeight / 2, }; return { x, y, width: Math.max(upperWidth, lowerWidth), upperWidth, lowerWidth, height: rowHeight, name, val, tooltipPayload, tooltipPosition, ..._.omit(entry, 'width'), payload: entry, }; }); return { trapezoids, data: funnelData, onMouseLeave: _.isFunction(onItemMouseLeave) ? onItemMouseLeave : item.props.onMouseLeave, onMouseEnter: _.isFunction(onItemMouseEnter) ? onItemMouseEnter : item.props.onMouseEnter, onClick: _.isFunction(onItemClick) ? onItemClick : item.props.onClick, }; }; state: State = { isAnimationFinished: false }; // eslint-disable-next-line camelcase UNSAFE_componentWillReceiveProps(nextProps: Props) { const { animationId, trapezoids } = this.props; if (nextProps.isAnimationActive !== this.props.isAnimationActive) { this.cachePrevData([]); } else if (nextProps.animationId !== animationId) { this.cachePrevData(trapezoids); } } cachePrevData = (trapezoids: FunnelTrapezoidItem[]) => { this.setState({ prevTrapezoids: trapezoids }); }; handleAnimationEnd = () => { const { onAnimationEnd } = this.props; this.setState({ isAnimationFinished: true }); if (_.isFunction(onAnimationEnd)) { onAnimationEnd(); } }; handleAnimationStart = () => { const { onAnimationStart } = this.props; this.setState({ isAnimationFinished: false }); if (_.isFunction(onAnimationStart)) { onAnimationStart(); } }; isActiveIndex(i: number) { const { activeIndex } = this.props; if (Array.isArray(activeIndex)) { return activeIndex.indexOf(i) !== -1; } return i === activeIndex; } static renderTrapezoidItem(option: Props['activeShape'], props: any) { if (React.isValidElement(option)) { return React.cloneElement(option, props); } if (_.isFunction(option)) { return option(props); } if (_.isPlainObject(option)) { return ; } return ; } renderTrapezoidsStatically(trapezoids: FunnelTrapezoidItem[]) { const { activeShape } = this.props; return trapezoids.map((entry, i) => { const trapezoidOptions = this.isActiveIndex(i) ? activeShape : null; const trapezoidProps = { ...entry, stroke: entry.stroke, }; return ( {Funnel.renderTrapezoidItem(trapezoidOptions, trapezoidProps)} ); }); } renderTrapezoidsWithAnimation() { const { trapezoids, isAnimationActive, animationBegin, animationDuration, animationEasing, animationId, } = this.props; const { prevTrapezoids } = this.state; return ( {({ t }: { t: number }) => { const stepData = trapezoids.map((entry, index) => { const prev = prevTrapezoids && prevTrapezoids[index]; if (prev) { const interpolatorX = interpolateNumber(prev.x, entry.x); const interpolatorY = interpolateNumber(prev.y, entry.y); const interpolatorUpperWidth = interpolateNumber(prev.upperWidth, entry.upperWidth); const interpolatorLowerWidth = interpolateNumber(prev.lowerWidth, entry.lowerWidth); const interpolatorHeight = interpolateNumber(prev.height, entry.height); return { ...entry, x: interpolatorX(t), y: interpolatorY(t), upperWidth: interpolatorUpperWidth(t), lowerWidth: interpolatorLowerWidth(t), height: interpolatorHeight(t), }; } const interpolatorX = interpolateNumber(entry.x + entry.upperWidth / 2, entry.x); const interpolatorY = interpolateNumber(entry.y + entry.height / 2, entry.y); const interpolatorUpperWidth = interpolateNumber(0, entry.upperWidth); const interpolatorLowerWidth = interpolateNumber(0, entry.lowerWidth); const interpolatorHeight = interpolateNumber(0, entry.height); return { ...entry, x: interpolatorX(t), y: interpolatorY(t), upperWidth: interpolatorUpperWidth(t), lowerWidth: interpolatorLowerWidth(t), height: interpolatorHeight(t), }; }); return {this.renderTrapezoidsStatically(stepData)}; }} ); } renderTrapezoids() { const { trapezoids, isAnimationActive } = this.props; const { prevTrapezoids } = this.state; if ( isAnimationActive && trapezoids && trapezoids.length && (!prevTrapezoids || !_.isEqual(prevTrapezoids, trapezoids)) ) { return this.renderTrapezoidsWithAnimation(); } return this.renderTrapezoidsStatically(trapezoids); } render() { const { hide, trapezoids, className, isAnimationActive } = this.props; const { isAnimationFinished } = this.state; if (hide || !trapezoids || !trapezoids.length) { return null; } const layerClass = classNames('recharts-trapezoids', className); return ( {this.renderTrapezoids()} {(!isAnimationActive || isAnimationFinished) && LabelList.renderCallByParent(this.props, trapezoids)} ); } } export default Funnel;