import React, { Component, CSSProperties, MouseEvent, ReactElement, Children, } from 'react' import {connect} from 'react-redux' import _ from 'lodash' import NanoDate from 'nano-date' import ReactResizeDetector from 'react-resize-detector' import {getDeep} from 'src/utils/wrappers' import D from 'src/external/dygraph' import DygraphLegend from 'src/shared/components/DygraphLegend' import StaticLegend from 'src/shared/components/StaticLegend' import Annotations from 'src/shared/components/Annotations' import Crosshair from 'src/shared/components/Crosshair' import getRange, {getStackedRange} from 'src/shared/parsing/getRangeForDygraph' import { AXES_SCALE_OPTIONS, DEFAULT_AXIS, } from 'src/dashboards/constants/cellEditor' import {buildDefaultYLabel} from 'src/shared/presenters' import {numberValueFormatter} from 'src/utils/formatting' import {NULL_HOVER_TIME} from 'src/shared/constants/tableGraph' import { OPTIONS, LINE_COLORS, LABEL_WIDTH, CHAR_PIXELS, barPlotter, } from 'src/shared/graphs/helpers' import {ErrorHandling} from 'src/shared/decorators/errors' import {getLineColorsHexes} from 'src/shared/constants/graphColorPalettes' const {LOG, BASE_10, BASE_2} = AXES_SCALE_OPTIONS import { Axes, Query, RuleValues, TimeRange, DygraphData, DygraphClass, DygraphSeries, Constructable, } from 'src/types' import {LineColor} from 'src/types/colors' const Dygraphs = D as Constructable interface Props { cellID: string queries: Query[] timeSeries: DygraphData labels: string[] options: dygraphs.Options containerStyle: object // TODO dygraphSeries: DygraphSeries timeRange: TimeRange colors: LineColor[] handleSetHoverTime: (t: string) => void ruleValues?: RuleValues axes?: Axes isGraphFilled?: boolean isBarGraph?: boolean staticLegend?: boolean setResolution?: (w: number) => void onZoom?: (u: number | string, l: number | string) => void mode?: string } interface State { staticLegendHeight: null | number isMounted: boolean } @ErrorHandling class Dygraph extends Component { public static defaultProps: Partial = { axes: { x: { bounds: [null, null], ...DEFAULT_AXIS, }, y: { bounds: [null, null], ...DEFAULT_AXIS, }, y2: { bounds: undefined, ...DEFAULT_AXIS, }, }, containerStyle: {}, isGraphFilled: true, onZoom: () => {}, staticLegend: false, setResolution: () => {}, handleSetHoverTime: () => {}, } private graphRef: React.RefObject private dygraph: DygraphClass constructor(props: Props) { super(props) this.state = { staticLegendHeight: null, isMounted: false, } this.graphRef = React.createRef() } public componentDidMount() { const { axes: {y, y2}, isGraphFilled: fillGraph, isBarGraph, options, labels, } = this.props const timeSeries = this.timeSeries let defaultOptions = { ...options, labels, fillGraph, file: this.timeSeries, ylabel: this.getLabel('y'), logscale: y.scale === LOG, colors: LINE_COLORS, series: this.colorDygraphSeries, axes: { y: { valueRange: this.getYRange(timeSeries), axisLabelFormatter: ( yval: number, __, opts: (name: string) => number ) => numberValueFormatter(yval, opts, y.prefix, y.suffix), axisLabelWidth: this.labelWidth, labelsKMB: y.base === BASE_10, labelsKMG2: y.base === BASE_2, }, y2: { valueRange: getRange(timeSeries, y2.bounds), }, }, zoomCallback: (lower: number, upper: number) => this.handleZoom(lower, upper), highlightCircleSize: 0, } if (isBarGraph) { defaultOptions = { ...defaultOptions, plotter: barPlotter, } } this.dygraph = new Dygraphs(this.graphRef.current, timeSeries, { ...defaultOptions, ...OPTIONS, ...options, }) const {w} = this.dygraph.getArea() this.props.setResolution(w) this.setState({isMounted: true}) } public componentWillUnmount() { this.dygraph.destroy() delete this.dygraph } public shouldComponentUpdate(nextProps: Props, nextState: State) { const arePropsEqual = _.isEqual(this.props, nextProps) const areStatesEqual = _.isEqual(this.state, nextState) return !arePropsEqual || !areStatesEqual } public componentDidUpdate(prevProps: Props) { const { labels, axes: {y, y2}, options, isBarGraph, } = this.props const dygraph = this.dygraph if (!dygraph) { throw new Error( 'Dygraph not configured in time; this should not be possible!' ) } const timeSeries = this.timeSeries const timeRangeChanged = !_.isEqual( prevProps.timeRange, this.props.timeRange ) if (this.dygraph.isZoomed() && timeRangeChanged) { this.dygraph.resetZoom() } const updateOptions = { ...options, labels, file: timeSeries, logscale: y.scale === LOG, ylabel: this.getLabel('y'), axes: { y: { valueRange: this.getYRange(timeSeries), axisLabelFormatter: ( yval: number, __, opts: (name: string) => number ) => numberValueFormatter(yval, opts, y.prefix, y.suffix), axisLabelWidth: this.labelWidth, labelsKMB: y.base === BASE_10, labelsKMG2: y.base === BASE_2, }, y2: { valueRange: getRange(timeSeries, y2.bounds), }, }, colors: LINE_COLORS, series: this.colorDygraphSeries, plotter: isBarGraph ? barPlotter : null, } dygraph.updateOptions(updateOptions) const {w} = this.dygraph.getArea() this.props.setResolution(w) this.resize() } public render() { const {staticLegendHeight} = this.state const {staticLegend, cellID} = this.props return (
{this.dygraph && (
{this.areAnnotationsVisible && ( )}
)}
{staticLegend && ( )} {this.isGraphNested && React.cloneElement(this.nestedGraph, { staticLegendHeight, })}
) } private get nestedGraph(): ReactElement { const {children} = this.props const kids = Children.toArray(children) return _.get(kids, '0', null) } private get isGraphNested(): boolean { const {children} = this.props return children && Children.count(children) > 0 } private get dygraphStyle(): CSSProperties { const {containerStyle, staticLegend} = this.props const {staticLegendHeight} = this.state if (staticLegend) { const cellVerticalPadding = 16 return { ...containerStyle, zIndex: 2, height: `calc(100% - ${staticLegendHeight + cellVerticalPadding}px)`, } } return {...containerStyle, zIndex: 2} } private getYRange = (timeSeries: DygraphData): [number, number] => { const { options, axes: {y}, ruleValues, } = this.props if (options.stackedGraph) { return getStackedRange(y.bounds) } const range = getRange(timeSeries, y.bounds, ruleValues) const [min, max] = range // Bug in Dygraph calculates a negative range for logscale when min range is 0 if (y.scale === LOG && timeSeries.length === 1 && min <= 0) { return [0.1, max] } return range } private handleZoom = (lower: number, upper: number) => { const {onZoom} = this.props if (this.dygraph.isZoomed() === false) { return onZoom(null, null) } onZoom(this.formatTimeRange(lower), this.formatTimeRange(upper)) } private eventToTimestamp = ({ pageX: pxBetweenMouseAndPage, }: MouseEvent): string => { const { left: pxBetweenGraphAndPage, } = this.graphRef.current.getBoundingClientRect() const graphXCoordinate = pxBetweenMouseAndPage - pxBetweenGraphAndPage const timestamp = this.dygraph.toDataXCoord(graphXCoordinate) const [xRangeStart] = this.dygraph.xAxisRange() const clamped = Math.max(xRangeStart, timestamp) return `${clamped}` } private handleHideLegend = () => { this.props.handleSetHoverTime(NULL_HOVER_TIME) } private handleShowLegend = (e: MouseEvent): void => { const newTime = this.eventToTimestamp(e) this.props.handleSetHoverTime(newTime) } private get labelWidth() { const { axes: {y}, } = this.props return ( LABEL_WIDTH + y.prefix.length * CHAR_PIXELS + y.suffix.length * CHAR_PIXELS ) } private get timeSeries() { const {timeSeries} = this.props // Avoid 'Can't plot empty data set' errors by falling back to a // default dataset that's valid for Dygraph. return timeSeries.length ? timeSeries : [[0]] } private get colorDygraphSeries() { const {dygraphSeries, colors} = this.props const numSeries = Object.keys(dygraphSeries).length const dygraphSeriesKeys = Object.keys(dygraphSeries).sort() const lineColors = getLineColorsHexes(colors, numSeries) const coloredDygraphSeries = {} for (const seriesName in dygraphSeries) { if (dygraphSeries.hasOwnProperty(seriesName)) { const series = dygraphSeries[seriesName] const color = lineColors[dygraphSeriesKeys.indexOf(seriesName)] coloredDygraphSeries[seriesName] = {...series, color} } } return coloredDygraphSeries } private get areAnnotationsVisible() { if (!this.dygraph) { return false } const [start, end] = this.dygraph && this.dygraph.xAxisRange() return !!start && !!end } private getLabel = (axis: string): string => { const {axes, queries} = this.props const label = getDeep(axes, `${axis}.label`, '') const queryConfig = _.get(queries, ['0', 'queryConfig'], false) if (label || !queryConfig) { return label } return buildDefaultYLabel(queryConfig) } private resize = () => { this.dygraph.resizeElements_() this.dygraph.predraw_() this.dygraph.resize() } private formatTimeRange = (date: number): string => { if (!date) { return '' } const nanoDate = new NanoDate(date) return nanoDate.toISOString() } private handleReceiveStaticLegendHeight = (staticLegendHeight: number) => { this.setState({staticLegendHeight}) } } const mapStateToProps = ({annotations: {mode}}) => ({ mode, }) export default connect(mapStateToProps, null)(Dygraph)