474 lines
12 KiB
TypeScript
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)
|