chronograf/ui/src/shared/components/Dygraph.tsx

474 lines
12 KiB
TypeScript

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<DygraphClass>
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<Props, State> {
public static defaultProps: Partial<Props> = {
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<HTMLDivElement>
private dygraph: DygraphClass
constructor(props: Props) {
super(props)
this.state = {
staticLegendHeight: null,
isMounted: false,
}
this.graphRef = React.createRef<HTMLDivElement>()
}
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 (
<div className="dygraph-child">
{this.dygraph && (
<div className="dygraph-addons">
{this.areAnnotationsVisible && (
<Annotations
dygraph={this.dygraph}
dWidth={this.dygraph.width_}
staticLegendHeight={staticLegendHeight}
/>
)}
<DygraphLegend
cellID={cellID}
dygraph={this.dygraph}
onHide={this.handleHideLegend}
onShow={this.handleShowLegend}
/>
<Crosshair
dygraph={this.dygraph}
staticLegendHeight={staticLegendHeight}
/>
</div>
)}
<div
className="dygraph-child-container"
style={this.dygraphStyle}
onMouseEnter={this.handleShowLegend}
/>
{staticLegend && (
<StaticLegend
dygraphSeries={this.colorDygraphSeries}
dygraph={this.dygraph}
handleReceiveStaticLegendHeight={
this.handleReceiveStaticLegendHeight
}
/>
)}
{this.isGraphNested &&
React.cloneElement(this.nestedGraph, {
staticLegendHeight,
})}
<div
className="dygraph-child-container"
ref={this.graphRef}
style={this.dygraphStyle}
onMouseEnter={this.handleShowLegend}
/>
<ReactResizeDetector
handleWidth={true}
handleHeight={true}
onResize={this.resize}
/>
</div>
)
}
private get nestedGraph(): ReactElement<any> {
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<HTMLDivElement>): 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<HTMLDivElement>): 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<string>(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)