Просмотр исходного кода

Merge pull request #14014 from miqh/feat/explore-toggle-series

Add ability to toggle visibility of graph series in explore section
David 7 лет назад
Родитель
Сommit
1125ca4d79

+ 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 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<string>;
   showAllTimeSeries: boolean;
 }
 
 export class Graph extends PureComponent<GraphProps, GraphState> {
   $el: any;
+  dynamicOptions = null;
 
   state = {
+    hiddenSeries: new Set(),
     showAllTimeSeries: false,
   };
 
@@ -107,13 +115,14 @@ export class Graph extends PureComponent<GraphProps, GraphState> {
     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,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 = () => {
     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() {
-    const { range, size, userOptions = {} } = this.props;
+    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.map((ts: TimeSeries) => ({
+      series = data.filter((ts: TimeSeries) => !hiddenSeries.has(ts.alias)).map((ts: TimeSeries) => ({
         color: ts.color,
         label: ts.label,
         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 = {
       ...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<GraphProps, GraphState> {
             </div>
           )}
         <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() {
-    const { className = '', data } = this.props;
+    const { data, hiddenSeries } = this.props;
     const items = data || [];
     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>
     );
   }

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

+ 52 - 0
public/app/features/explore/utils/set.test.ts

@@ -0,0 +1,52 @@
+import { equal, intersect } from './set';
+
+describe('equal', () => {
+  it('returns false for two sets of differing sizes', () => {
+    const s1 = new Set([1, 2, 3]);
+    const s2 = new Set([4, 5, 6, 7]);
+    expect(equal(s1, s2)).toBe(false);
+  });
+  it('returns false for two sets where one is a subset of the other', () => {
+    const s1 = new Set([1, 2, 3]);
+    const s2 = new Set([1, 2, 3, 4]);
+    expect(equal(s1, s2)).toBe(false);
+  });
+  it('returns false for two sets with uncommon elements', () => {
+    const s1 = new Set([1, 2, 3, 4]);
+    const s2 = new Set([1, 2, 5, 6]);
+    expect(equal(s1, s2)).toBe(false);
+  });
+  it('returns false for two deeply equivalent sets', () => {
+    const s1 = new Set([{ a: 1 }, { b: 2 }, { c: 3 }, { d: 4 }]);
+    const s2 = new Set([{ a: 1 }, { b: 2 }, { c: 3 }, { d: 4 }]);
+    expect(equal(s1, s2)).toBe(false);
+  });
+  it('returns true for two sets with the same elements', () => {
+    const s1 = new Set([1, 2, 3, 4]);
+    const s2 = new Set([4, 3, 2, 1]);
+    expect(equal(s1, s2)).toBe(true);
+  });
+});
+
+describe('intersect', () => {
+  it('returns an empty set for two sets without any common elements', () => {
+    const s1 = new Set([1, 2, 3, 4]);
+    const s2 = new Set([5, 6, 7, 8]);
+    expect(intersect(s1, s2)).toEqual(new Set());
+  });
+  it('returns an empty set for two deeply equivalent sets', () => {
+    const s1 = new Set([{ a: 1 }, { b: 2 }, { c: 3 }, { d: 4 }]);
+    const s2 = new Set([{ a: 1 }, { b: 2 }, { c: 3 }, { d: 4 }]);
+    expect(intersect(s1, s2)).toEqual(new Set());
+  });
+  it('returns a set containing common elements between two sets of the same size', () => {
+    const s1 = new Set([1, 2, 3, 4]);
+    const s2 = new Set([5, 2, 7, 4]);
+    expect(intersect(s1, s2)).toEqual(new Set([2, 4]));
+  });
+  it('returns a set containing common elements between two sets of differing sizes', () => {
+    const s1 = new Set([1, 2, 3, 4]);
+    const s2 = new Set([5, 4, 3, 2, 1]);
+    expect(intersect(s1, s2)).toEqual(new Set([1, 2, 3, 4]));
+  });
+});

+ 35 - 0
public/app/features/explore/utils/set.ts

@@ -0,0 +1,35 @@
+/**
+ * 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 (done) {
+      return true;
+    }
+    if (!b.has(value)) {
+      return false;
+    }
+  }
+}
+
+/**
+ * Returns a new set with items in both sets using shallow comparison.
+ */
+export function intersect<T>(a: Set<T>, b: Set<T>): Set<T> {
+  const result = new Set<T>();
+  const it = b.values();
+  while (true) {
+    const { value, done } = it.next();
+    if (done) {
+      return result;
+    }
+    if (a.has(value)) {
+      result.add(value);
+    }
+  }
+}