diff --git a/public/app/features/explore/Graph.tsx b/public/app/features/explore/Graph.tsx index 61cf5753b19..01e9d5e7921 100644 --- a/public/app/features/explore/Graph.tsx +++ b/public/app/features/explore/Graph.tsx @@ -13,6 +13,7 @@ import * as dateMath from 'app/core/utils/datemath'; import TimeSeries from 'app/core/time_series2'; import Legend from './Legend'; +import { equal, intersect } from './utils/set'; const MAX_NUMBER_OF_TIME_SERIES = 20; @@ -85,13 +86,20 @@ interface GraphProps { } interface GraphState { + /** + * Type parameter refers to the `alias` property of a `TimeSeries`. + * Consequently, all series sharing the same alias will share visibility state. + */ + hiddenSeries: Set; showAllTimeSeries: boolean; } export class Graph extends PureComponent { $el: any; + dynamicOptions = null; state = { + hiddenSeries: new Set(), showAllTimeSeries: false, }; @@ -107,13 +115,14 @@ export class Graph extends PureComponent { this.$el.bind('plotselected', this.onPlotSelected); } - componentDidUpdate(prevProps: GraphProps) { + componentDidUpdate(prevProps: GraphProps, prevState: GraphState) { if ( prevProps.data !== this.props.data || prevProps.range !== this.props.range || prevProps.split !== this.props.split || prevProps.height !== this.props.height || - (prevProps.size && prevProps.size.width !== this.props.size.width) + (prevProps.size && prevProps.size.width !== this.props.size.width) || + !equal(prevState.hiddenSeries, this.state.hiddenSeries) ) { this.draw(); } @@ -133,30 +142,8 @@ export class Graph extends PureComponent { } }; - onShowAllTimeSeries = () => { - this.setState( - { - showAllTimeSeries: true, - }, - this.draw - ); - }; - - draw() { - const { range, size, userOptions = {} } = this.props; - const data = this.getGraphData(); - - const $el = $(`#${this.props.id}`); - let series = [{ data: [[0, 0]] }]; - - if (data && data.length > 0) { - series = data.map((ts: TimeSeries) => ({ - color: ts.color, - label: ts.label, - data: ts.getFlotPairs('null'), - })); - } - + getDynamicOptions() { + const { range, size } = this.props; const ticks = (size.width || 0) / 100; let { from, to } = range; if (!moment.isMoment(from)) { @@ -167,7 +154,7 @@ export class Graph extends PureComponent { } const min = from.valueOf(); const max = to.valueOf(); - const dynamicOptions = { + return { xaxis: { mode: 'time', min: min, @@ -178,16 +165,76 @@ export class Graph extends PureComponent { timeformat: time_format(ticks, min, max), }, }; + } + + onShowAllTimeSeries = () => { + this.setState( + { + showAllTimeSeries: true, + }, + this.draw + ); + }; + + onToggleSeries = (series: TimeSeries, exclusive: boolean) => { + this.setState((state, props) => { + const { data } = props; + const { hiddenSeries } = state; + const hidden = hiddenSeries.has(series.alias); + // Deduplicate series as visibility tracks the alias property + const oneSeriesVisible = hiddenSeries.size === new Set(data.map(d => d.alias)).size - 1; + if (exclusive) { + return { + hiddenSeries: + !hidden && oneSeriesVisible + ? new Set() + : new Set(data.filter(d => d.alias !== series.alias).map(d => d.alias)), + }; + } + // Prune hidden series no longer part of those available from the most recent query + const availableSeries = new Set(data.map(d => d.alias)); + const nextHiddenSeries = intersect(new Set(hiddenSeries), availableSeries); + if (nextHiddenSeries.has(series.alias)) { + nextHiddenSeries.delete(series.alias); + } else { + nextHiddenSeries.add(series.alias); + } + return { + hiddenSeries: nextHiddenSeries, + }; + }, this.draw); + }; + + draw() { + const { userOptions = {} } = this.props; + const { hiddenSeries } = this.state; + const data = this.getGraphData(); + + const $el = $(`#${this.props.id}`); + let series = [{ data: [[0, 0]] }]; + + if (data && data.length > 0) { + series = data.filter((ts: TimeSeries) => !hiddenSeries.has(ts.alias)).map((ts: TimeSeries) => ({ + color: ts.color, + label: ts.label, + data: ts.getFlotPairs('null'), + })); + } + + this.dynamicOptions = this.getDynamicOptions(); + const options = { ...FLOT_OPTIONS, - ...dynamicOptions, + ...this.dynamicOptions, ...userOptions, }; + $.plot($el, series, options); } render() { const { height = '100px', id = 'graph' } = this.props; + const { hiddenSeries } = this.state; const data = this.getGraphData(); return ( @@ -204,7 +251,7 @@ export class Graph extends PureComponent { )}
- + ); } diff --git a/public/app/features/explore/Legend.tsx b/public/app/features/explore/Legend.tsx index 439b6c3e54f..3b67aa74d91 100644 --- a/public/app/features/explore/Legend.tsx +++ b/public/app/features/explore/Legend.tsx @@ -1,23 +1,65 @@ -import React, { PureComponent } from 'react'; +import React, { MouseEvent, PureComponent } from 'react'; +import classNames from 'classnames'; +import { TimeSeries } from 'app/core/core'; -const LegendItem = ({ series }) => ( - -); +interface LegendProps { + data: TimeSeries[]; + hiddenSeries: Set; + onToggleSeries?: (series: TimeSeries, exclusive: boolean) => void; +} + +interface LegendItemProps { + hidden: boolean; + onClickLabel?: (series: TimeSeries, event: MouseEvent) => void; + series: TimeSeries; +} + +class LegendItem extends PureComponent { + onClickLabel = e => this.props.onClickLabel(this.props.series, e); -export default class Legend extends PureComponent { render() { - const { className = '', data } = this.props; - const items = data || []; + const { hidden, series } = this.props; + const seriesClasses = classNames({ + 'graph-legend-series-hidden': hidden, + }); return ( -
- {items.map(series => )} + + ); + } +} + +export default class Legend extends PureComponent { + static defaultProps = { + onToggleSeries: () => {}, + }; + + onClickLabel = (series: TimeSeries, event: MouseEvent) => { + const { onToggleSeries } = this.props; + const exclusive = event.ctrlKey || event.metaKey || event.shiftKey; + onToggleSeries(series, !exclusive); + }; + + render() { + const { data, hiddenSeries } = this.props; + const items = data || []; + return ( +
+ {items.map((series, i) => ( +
); } diff --git a/public/app/features/explore/__snapshots__/Graph.test.tsx.snap b/public/app/features/explore/__snapshots__/Graph.test.tsx.snap index 6b9553d2e1d..a7ec6deb22c 100644 --- a/public/app/features/explore/__snapshots__/Graph.test.tsx.snap +++ b/public/app/features/explore/__snapshots__/Graph.test.tsx.snap @@ -453,6 +453,8 @@ exports[`Render should render component 1`] = ` }, ] } + hiddenSeries={Set {}} + onToggleSeries={[Function]} /> `; @@ -947,6 +949,8 @@ exports[`Render should render component with disclaimer 1`] = ` }, ] } + hiddenSeries={Set {}} + onToggleSeries={[Function]} /> `; @@ -964,6 +968,8 @@ exports[`Render should show query return no time series 1`] = ` /> `; diff --git a/public/app/features/explore/utils/set.ts b/public/app/features/explore/utils/set.ts new file mode 100644 index 00000000000..a625567ac18 --- /dev/null +++ b/public/app/features/explore/utils/set.ts @@ -0,0 +1,34 @@ +/** + * Performs a shallow comparison of two sets with the same item type. + */ +export function equal(a: Set, b: Set): boolean { + if (a.size !== b.size) { + return false; + } + const it = a.values(); + while (true) { + const { value, done } = it.next(); + if (b.has(value)) { + return false; + } + if (done) { + return true; + } + } +} + +/** + * Returns the first set with items in the second set through shallow comparison. + */ +export function intersect(a: Set, b: Set): Set { + const it = b.values(); + while (true) { + const { value, done } = it.next(); + if (!a.has(value)) { + a.delete(value); + } + if (done) { + return a; + } + } +}