From c7dc557e918e4290b610924d6c66b28e0b43e15f Mon Sep 17 00:00:00 2001 From: Michael Huynh Date: Thu, 15 Nov 2018 21:56:52 +0800 Subject: [PATCH] Add visibility toggle for explore graph series The implemented toggling UX is similar to how the dashboard graph plugin behaves. Also incorporates review feedback to persist series visibility state by means of the alias property, with the limitation it carries too. Related: #13522 --- public/app/features/explore/Graph.tsx | 105 +++++++++++++----- public/app/features/explore/Legend.tsx | 74 +++++++++--- .../explore/__snapshots__/Graph.test.tsx.snap | 6 + public/app/features/explore/utils/set.ts | 34 ++++++ 4 files changed, 174 insertions(+), 45 deletions(-) create mode 100644 public/app/features/explore/utils/set.ts 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; + } + } +}