瀏覽代碼

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
Michael Huynh 7 年之前
父節點
當前提交
c7dc557e91

+ 74 - 27
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 TimeSeries from 'app/core/time_series2';
 
 
 import Legend from './Legend';
 import Legend from './Legend';
+import { equal, intersect } from './utils/set';
 
 
 const MAX_NUMBER_OF_TIME_SERIES = 20;
 const MAX_NUMBER_OF_TIME_SERIES = 20;
 
 
@@ -85,13 +86,20 @@ interface GraphProps {
 }
 }
 
 
 interface GraphState {
 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<string>;
   showAllTimeSeries: boolean;
   showAllTimeSeries: boolean;
 }
 }
 
 
 export class Graph extends PureComponent<GraphProps, GraphState> {
 export class Graph extends PureComponent<GraphProps, GraphState> {
   $el: any;
   $el: any;
+  dynamicOptions = null;
 
 
   state = {
   state = {
+    hiddenSeries: new Set(),
     showAllTimeSeries: false,
     showAllTimeSeries: false,
   };
   };
 
 
@@ -107,13 +115,14 @@ export class Graph extends PureComponent<GraphProps, GraphState> {
     this.$el.bind('plotselected', this.onPlotSelected);
     this.$el.bind('plotselected', this.onPlotSelected);
   }
   }
 
 
-  componentDidUpdate(prevProps: GraphProps) {
+  componentDidUpdate(prevProps: GraphProps, prevState: GraphState) {
     if (
     if (
       prevProps.data !== this.props.data ||
       prevProps.data !== this.props.data ||
       prevProps.range !== this.props.range ||
       prevProps.range !== this.props.range ||
       prevProps.split !== this.props.split ||
       prevProps.split !== this.props.split ||
       prevProps.height !== this.props.height ||
       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();
       this.draw();
     }
     }
@@ -133,6 +142,31 @@ export class Graph extends PureComponent<GraphProps, GraphState> {
     }
     }
   };
   };
 
 
+  getDynamicOptions() {
+    const { range, size } = this.props;
+    const ticks = (size.width || 0) / 100;
+    let { from, to } = range;
+    if (!moment.isMoment(from)) {
+      from = dateMath.parse(from, false);
+    }
+    if (!moment.isMoment(to)) {
+      to = dateMath.parse(to, true);
+    }
+    const min = from.valueOf();
+    const max = to.valueOf();
+    return {
+      xaxis: {
+        mode: 'time',
+        min: min,
+        max: max,
+        label: 'Datetime',
+        ticks: ticks,
+        timezone: 'browser',
+        timeformat: time_format(ticks, min, max),
+      },
+    };
+  }
+
   onShowAllTimeSeries = () => {
   onShowAllTimeSeries = () => {
     this.setState(
     this.setState(
       {
       {
@@ -142,52 +176,65 @@ export class Graph extends PureComponent<GraphProps, GraphState> {
     );
     );
   };
   };
 
 
+  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() {
   draw() {
-    const { range, size, userOptions = {} } = this.props;
+    const { userOptions = {} } = this.props;
+    const { hiddenSeries } = this.state;
     const data = this.getGraphData();
     const data = this.getGraphData();
 
 
     const $el = $(`#${this.props.id}`);
     const $el = $(`#${this.props.id}`);
     let series = [{ data: [[0, 0]] }];
     let series = [{ data: [[0, 0]] }];
 
 
     if (data && data.length > 0) {
     if (data && data.length > 0) {
-      series = data.map((ts: TimeSeries) => ({
+      series = data.filter((ts: TimeSeries) => !hiddenSeries.has(ts.alias)).map((ts: TimeSeries) => ({
         color: ts.color,
         color: ts.color,
         label: ts.label,
         label: ts.label,
         data: ts.getFlotPairs('null'),
         data: ts.getFlotPairs('null'),
       }));
       }));
     }
     }
 
 
-    const ticks = (size.width || 0) / 100;
-    let { from, to } = range;
-    if (!moment.isMoment(from)) {
-      from = dateMath.parse(from, false);
-    }
-    if (!moment.isMoment(to)) {
-      to = dateMath.parse(to, true);
-    }
-    const min = from.valueOf();
-    const max = to.valueOf();
-    const dynamicOptions = {
-      xaxis: {
-        mode: 'time',
-        min: min,
-        max: max,
-        label: 'Datetime',
-        ticks: ticks,
-        timezone: 'browser',
-        timeformat: time_format(ticks, min, max),
-      },
-    };
+    this.dynamicOptions = this.getDynamicOptions();
+
     const options = {
     const options = {
       ...FLOT_OPTIONS,
       ...FLOT_OPTIONS,
-      ...dynamicOptions,
+      ...this.dynamicOptions,
       ...userOptions,
       ...userOptions,
     };
     };
+
     $.plot($el, series, options);
     $.plot($el, series, options);
   }
   }
 
 
   render() {
   render() {
     const { height = '100px', id = 'graph' } = this.props;
     const { height = '100px', id = 'graph' } = this.props;
+    const { hiddenSeries } = this.state;
     const data = this.getGraphData();
     const data = this.getGraphData();
 
 
     return (
     return (
@@ -204,7 +251,7 @@ export class Graph extends PureComponent<GraphProps, GraphState> {
             </div>
             </div>
           )}
           )}
         <div id={id} className="explore-graph" style={{ height }} />
         <div id={id} className="explore-graph" style={{ height }} />
-        <Legend data={data} />
+        <Legend data={data} hiddenSeries={hiddenSeries} onToggleSeries={this.onToggleSeries} />
       </>
       </>
     );
     );
   }
   }

+ 57 - 15
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 }) => (
-  <div className="graph-legend-series">
-    <div className="graph-legend-icon">
-      <i className="fa fa-minus pointer" style={{ color: series.color }} />
-    </div>
-    <a className="graph-legend-alias pointer" title={series.alias}>
-      {series.alias}
-    </a>
-  </div>
-);
+interface LegendProps {
+  data: TimeSeries[];
+  hiddenSeries: Set<string>;
+  onToggleSeries?: (series: TimeSeries, exclusive: boolean) => void;
+}
+
+interface LegendItemProps {
+  hidden: boolean;
+  onClickLabel?: (series: TimeSeries, event: MouseEvent) => void;
+  series: TimeSeries;
+}
+
+class LegendItem extends PureComponent<LegendItemProps> {
+  onClickLabel = e => this.props.onClickLabel(this.props.series, e);
+
+  render() {
+    const { hidden, series } = this.props;
+    const seriesClasses = classNames({
+      'graph-legend-series-hidden': hidden,
+    });
+    return (
+      <div className={`graph-legend-series ${seriesClasses}`}>
+        <div className="graph-legend-icon">
+          <i className="fa fa-minus pointer" style={{ color: series.color }} />
+        </div>
+        <a className="graph-legend-alias pointer" title={series.alias} onClick={this.onClickLabel}>
+          {series.alias}
+        </a>
+      </div>
+    );
+  }
+}
+
+export default class Legend extends PureComponent<LegendProps> {
+  static defaultProps = {
+    onToggleSeries: () => {},
+  };
+
+  onClickLabel = (series: TimeSeries, event: MouseEvent) => {
+    const { onToggleSeries } = this.props;
+    const exclusive = event.ctrlKey || event.metaKey || event.shiftKey;
+    onToggleSeries(series, !exclusive);
+  };
 
 
-export default class Legend extends PureComponent<any, any> {
   render() {
   render() {
-    const { className = '', data } = this.props;
+    const { data, hiddenSeries } = this.props;
     const items = data || [];
     const items = data || [];
     return (
     return (
-      <div className={`${className} graph-legend ps`}>
-        {items.map(series => <LegendItem key={series.id} series={series} />)}
+      <div className="graph-legend ps">
+        {items.map((series, i) => (
+          <LegendItem
+            hidden={hiddenSeries.has(series.alias)}
+            // Workaround to resolve conflicts since series visibility tracks the alias property
+            key={`${series.id}-${i}`}
+            onClickLabel={this.onClickLabel}
+            series={series}
+          />
+        ))}
       </div>
       </div>
     );
     );
   }
   }

+ 6 - 0
public/app/features/explore/__snapshots__/Graph.test.tsx.snap

@@ -453,6 +453,8 @@ exports[`Render should render component 1`] = `
         },
         },
       ]
       ]
     }
     }
+    hiddenSeries={Set {}}
+    onToggleSeries={[Function]}
   />
   />
 </Fragment>
 </Fragment>
 `;
 `;
@@ -947,6 +949,8 @@ exports[`Render should render component with disclaimer 1`] = `
         },
         },
       ]
       ]
     }
     }
+    hiddenSeries={Set {}}
+    onToggleSeries={[Function]}
   />
   />
 </Fragment>
 </Fragment>
 `;
 `;
@@ -964,6 +968,8 @@ exports[`Render should show query return no time series 1`] = `
   />
   />
   <Legend
   <Legend
     data={Array []}
     data={Array []}
+    hiddenSeries={Set {}}
+    onToggleSeries={[Function]}
   />
   />
 </Fragment>
 </Fragment>
 `;
 `;

+ 34 - 0
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<T>(a: Set<T>, b: Set<T>): 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<T>(a: Set<T>, b: Set<T>): Set<T> {
+  const it = b.values();
+  while (true) {
+    const { value, done } = it.next();
+    if (!a.has(value)) {
+      a.delete(value);
+    }
+    if (done) {
+      return a;
+    }
+  }
+}