/** * @fileOverview Cartesian Axis */ import React, { ReactElement, ReactNode, Component, SVGProps } from 'react'; import _ from 'lodash'; import classNames from 'classnames'; import { shallowEqual } from '../util/ShallowEqual'; import { getStringSize } from '../util/DOMUtils'; import { Layer } from '../container/Layer'; import { Text } from '../component/Text'; import { Label } from '../component/Label'; import { Global } from '../util/Global'; import { isNumber, mathSign } from '../util/DataUtils'; import { CartesianViewBox, filterProps, TickItem, adaptEventsOfChild, PresentationAttributesAdaptChildEvent, } from '../util/types'; interface CartesianTickItem extends TickItem { tickCoord?: number; tickSize?: number; isShow?: boolean; } export interface CartesianAxisProps { className?: string; x?: number; y?: number; width?: number; height?: number; unit?: string | number; orientation?: 'top' | 'bottom' | 'left' | 'right'; // The viewBox of svg viewBox?: CartesianViewBox; tick?: SVGProps | ReactElement | ((props: any) => ReactElement) | boolean; axisLine?: boolean | SVGProps; tickLine?: boolean | SVGProps; mirror?: boolean; tickMargin?: number; hide?: boolean; label?: any; minTickGap?: number; ticks?: CartesianTickItem[]; tickSize?: number; /** The formatter function of tick */ tickFormatter?: (value: any, index: number) => string; ticksGenerator?: (props?: CartesianAxisProps) => CartesianTickItem[]; interval?: number | 'preserveStart' | 'preserveEnd' | 'preserveStartEnd'; } interface IState { fontSize: string; letterSpacing: string; } export type Props = Omit, 'viewBox'> & CartesianAxisProps; export class CartesianAxis extends Component { static displayName = 'CartesianAxis'; static defaultProps = { x: 0, y: 0, width: 0, height: 0, viewBox: { x: 0, y: 0, width: 0, height: 0 }, // The orientation of axis orientation: 'bottom', // The ticks ticks: [] as CartesianAxisProps['ticks'], stroke: '#666', tickLine: true, axisLine: true, tick: true, mirror: false, minTickGap: 5, // The width or height of tick tickSize: 6, tickMargin: 2, interval: 'preserveEnd', }; private layerReference: any; constructor(props: Props) { super(props); this.state = { fontSize: '', letterSpacing: '' }; } // todo Array static getTicks(props: Props, fontSize?: string, letterSpacing?: string): any[] { const { tick, ticks, viewBox, minTickGap, orientation, interval, tickFormatter, unit } = props; if (!ticks || !ticks.length || !tick) { return []; } if (isNumber(interval) || Global.isSsr) { return CartesianAxis.getNumberIntervalTicks( ticks, typeof interval === 'number' && isNumber(interval) ? interval : 0, ); } if (interval === 'preserveStartEnd') { return CartesianAxis.getTicksStart( { ticks, tickFormatter, viewBox, orientation, minTickGap, unit, fontSize, letterSpacing, }, true, ); } if (interval === 'preserveStart') { return CartesianAxis.getTicksStart({ ticks, tickFormatter, viewBox, orientation, minTickGap, unit, fontSize, letterSpacing, }); } return CartesianAxis.getTicksEnd({ ticks, tickFormatter, viewBox, orientation, minTickGap, unit, fontSize, letterSpacing, }); } static getNumberIntervalTicks(ticks: CartesianTickItem[], interval: number) { return ticks.filter((entry, i) => i % (interval + 1) === 0); } static getTicksStart( { ticks, tickFormatter, viewBox, orientation, minTickGap, unit, fontSize, letterSpacing, }: Omit, preserveEnd?: boolean, ) { const { x, y, width, height } = viewBox; const sizeKey = orientation === 'top' || orientation === 'bottom' ? 'width' : 'height'; const result = (ticks || []).slice(); // we need add the width of 'unit' only when sizeKey === 'width' const unitSize = unit && sizeKey === 'width' ? getStringSize(unit, { fontSize, letterSpacing })[sizeKey] : 0; const len = result.length; const sign = len >= 2 ? mathSign(result[1].coordinate - result[0].coordinate) : 1; let start, end; if (sign === 1) { start = sizeKey === 'width' ? x : y; end = sizeKey === 'width' ? x + width : y + height; } else { start = sizeKey === 'width' ? x + width : y + height; end = sizeKey === 'width' ? x : y; } if (preserveEnd) { // Try to guarantee the tail to be displayed let tail = ticks[len - 1]; const tailContent = _.isFunction(tickFormatter) ? tickFormatter(tail.value, len - 1) : tail.value; const tailSize = getStringSize(tailContent, { fontSize, letterSpacing })[sizeKey] + unitSize; const tailGap = sign * (tail.coordinate + (sign * tailSize) / 2 - end); result[len - 1] = tail = { ...tail, tickCoord: tailGap > 0 ? tail.coordinate - tailGap * sign : tail.coordinate, }; const isTailShow = sign * (tail.tickCoord - (sign * tailSize) / 2 - start) >= 0 && sign * (tail.tickCoord + (sign * tailSize) / 2 - end) <= 0; if (isTailShow) { end = tail.tickCoord - sign * (tailSize / 2 + minTickGap); result[len - 1] = { ...tail, isShow: true }; } } const count = preserveEnd ? len - 1 : len; for (let i = 0; i < count; i++) { let entry = result[i]; const content = _.isFunction(tickFormatter) ? tickFormatter(entry.value, i) : entry.value; const size = getStringSize(content, { fontSize, letterSpacing })[sizeKey] + unitSize; if (i === 0) { const gap = sign * (entry.coordinate - (sign * size) / 2 - start); result[i] = entry = { ...entry, tickCoord: gap < 0 ? entry.coordinate - gap * sign : entry.coordinate, }; } else { result[i] = entry = { ...entry, tickCoord: entry.coordinate }; } const isShow = sign * (entry.tickCoord - (sign * size) / 2 - start) >= 0 && sign * (entry.tickCoord + (sign * size) / 2 - end) <= 0; if (isShow) { start = entry.tickCoord + sign * (size / 2 + minTickGap); result[i] = { ...entry, isShow: true }; } } return result.filter(entry => entry.isShow); } static getTicksEnd({ ticks, tickFormatter, viewBox, orientation, minTickGap, unit, fontSize, letterSpacing, }: Omit) { const { x, y, width, height } = viewBox; const sizeKey = orientation === 'top' || orientation === 'bottom' ? 'width' : 'height'; // we need add the width of 'unit' only when sizeKey === 'width' const unitSize = unit && sizeKey === 'width' ? getStringSize(unit, { fontSize, letterSpacing })[sizeKey] : 0; const result = (ticks || []).slice(); const len = result.length; const sign = len >= 2 ? mathSign(result[1].coordinate - result[0].coordinate) : 1; let start, end; if (sign === 1) { start = sizeKey === 'width' ? x : y; end = sizeKey === 'width' ? x + width : y + height; } else { start = sizeKey === 'width' ? x + width : y + height; end = sizeKey === 'width' ? x : y; } for (let i = len - 1; i >= 0; i--) { let entry = result[i]; const content = _.isFunction(tickFormatter) ? tickFormatter(entry.value, len - i - 1) : entry.value; const size = getStringSize(content, { fontSize, letterSpacing })[sizeKey] + unitSize; if (i === len - 1) { const gap = sign * (entry.coordinate + (sign * size) / 2 - end); result[i] = entry = { ...entry, tickCoord: gap > 0 ? entry.coordinate - gap * sign : entry.coordinate, }; } else { result[i] = entry = { ...entry, tickCoord: entry.coordinate }; } const isShow = sign * (entry.tickCoord - (sign * size) / 2 - start) >= 0 && sign * (entry.tickCoord + (sign * size) / 2 - end) <= 0; if (isShow) { end = entry.tickCoord - sign * (size / 2 + minTickGap); result[i] = { ...entry, isShow: true }; } } return result.filter(entry => entry.isShow); } shouldComponentUpdate({ viewBox, ...restProps }: Props, nextState: IState) { // props.viewBox is sometimes generated every time - // check that specially as object equality is likely to fail const { viewBox: viewBoxOld, ...restPropsOld } = this.props; return ( !shallowEqual(viewBox, viewBoxOld) || !shallowEqual(restProps, restPropsOld) || !shallowEqual(nextState, this.state) ); } componentDidMount() { const htmlLayer: SVGElement = this.layerReference; if (!htmlLayer) return; const tick: Element = htmlLayer.getElementsByClassName('recharts-cartesian-axis-tick-value')[0]; if (tick) { this.setState({ fontSize: window.getComputedStyle(tick).fontSize, letterSpacing: window.getComputedStyle(tick).letterSpacing, }); } } /** * Calculate the coordinates of endpoints in ticks * @param {Object} data The data of a simple tick * @return {Object} (x1, y1): The coordinate of endpoint close to tick text * (x2, y2): The coordinate of endpoint close to axis */ getTickLineCoord(data: CartesianTickItem) { const { x, y, width, height, orientation, tickSize, mirror, tickMargin } = this.props; let x1, x2, y1, y2, tx, ty; const sign = mirror ? -1 : 1; const finalTickSize = data.tickSize || tickSize; const tickCoord = isNumber(data.tickCoord) ? data.tickCoord : data.coordinate; switch (orientation) { case 'top': x1 = x2 = data.coordinate; y2 = y + +!mirror * height; y1 = y2 - sign * finalTickSize; ty = y1 - sign * tickMargin; tx = tickCoord; break; case 'left': y1 = y2 = data.coordinate; x2 = x + +!mirror * width; x1 = x2 - sign * finalTickSize; tx = x1 - sign * tickMargin; ty = tickCoord; break; case 'right': y1 = y2 = data.coordinate; x2 = x + +mirror * width; x1 = x2 + sign * finalTickSize; tx = x1 + sign * tickMargin; ty = tickCoord; break; default: x1 = x2 = data.coordinate; y2 = y + +mirror * height; y1 = y2 + sign * finalTickSize; ty = y1 + sign * tickMargin; tx = tickCoord; break; } return { line: { x1, y1, x2, y2 }, tick: { x: tx, y: ty } }; } getTickTextAnchor() { const { orientation, mirror } = this.props; let textAnchor; switch (orientation) { case 'left': textAnchor = mirror ? 'start' : 'end'; break; case 'right': textAnchor = mirror ? 'end' : 'start'; break; default: textAnchor = 'middle'; break; } return textAnchor; } getTickVerticalAnchor() { const { orientation, mirror } = this.props; let verticalAnchor = 'end'; switch (orientation) { case 'left': case 'right': verticalAnchor = 'middle'; break; case 'top': verticalAnchor = mirror ? 'start' : 'end'; break; default: verticalAnchor = mirror ? 'end' : 'start'; break; } return verticalAnchor; } renderAxisLine() { const { x, y, width, height, orientation, mirror, axisLine } = this.props; let props: SVGProps = { ...filterProps(this.props), ...filterProps(axisLine), fill: 'none', }; if (orientation === 'top' || orientation === 'bottom') { const needHeight = +((orientation === 'top' && !mirror) || (orientation === 'bottom' && mirror)); props = { ...props, x1: x, y1: y + needHeight * height, x2: x + width, y2: y + needHeight * height, }; } else { const needWidth = +((orientation === 'left' && !mirror) || (orientation === 'right' && mirror)); props = { ...props, x1: x + needWidth * width, y1: y, x2: x + needWidth * width, y2: y + height, }; } return ; } static renderTickItem(option: Props['tick'], props: any, value: ReactNode) { let tickItem; if (React.isValidElement(option)) { tickItem = React.cloneElement(option, props); } else if (_.isFunction(option)) { tickItem = option(props); } else { tickItem = ( {value} ); } return tickItem; } /** * render the ticks * @param {Array} ticks The ticks to actually render (overrides what was passed in props) * @param {string} fontSize Fontsize to consider for tick spacing * @param {string} letterSpacing Letterspacing to consider for tick spacing * @return {ReactComponent} renderedTicks */ renderTicks(ticks: CartesianTickItem[], fontSize: string, letterSpacing: string) { const { tickLine, stroke, tick, tickFormatter, unit } = this.props; const finalTicks = CartesianAxis.getTicks({ ...this.props, ticks }, fontSize, letterSpacing); const textAnchor = this.getTickTextAnchor(); const verticalAnchor = this.getTickVerticalAnchor(); const axisProps = filterProps(this.props); const customTickProps = filterProps(tick); const tickLineProps = { ...axisProps, fill: 'none', ...filterProps(tickLine), }; const items = finalTicks.map((entry, i) => { const { line: lineCoord, tick: tickCoord } = this.getTickLineCoord(entry); const tickProps = { textAnchor, verticalAnchor, ...axisProps, stroke: 'none', fill: stroke, ...customTickProps, ...tickCoord, index: i, payload: entry, visibleTicksCount: finalTicks.length, tickFormatter, }; return ( {tickLine && ( )} {tick && CartesianAxis.renderTickItem( tick, tickProps, `${_.isFunction(tickFormatter) ? tickFormatter(entry.value, i) : entry.value}${unit || ''}`, )} ); }); return {items}; } render() { const { axisLine, width, height, ticksGenerator, className, hide } = this.props; if (hide) { return null; } const { ticks, ...noTicksProps } = this.props; let finalTicks = ticks; if (_.isFunction(ticksGenerator)) { finalTicks = ticks && ticks.length > 0 ? ticksGenerator(this.props) : ticksGenerator(noTicksProps); } if (width <= 0 || height <= 0 || !finalTicks || !finalTicks.length) { return null; } return ( { this.layerReference = ref; }} > {axisLine && this.renderAxisLine()} {this.renderTicks(finalTicks, this.state.fontSize, this.state.letterSpacing)} {Label.renderCallByParent(this.props)} ); } }