mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Merge pull request #14014 from miqh/feat/explore-toggle-series
Add ability to toggle visibility of graph series in explore section
This commit is contained in:
commit
1125ca4d79
@ -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,30 +142,8 @@ export class Graph extends PureComponent<GraphProps, GraphState> {
|
||||
}
|
||||
};
|
||||
|
||||
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<GraphProps, GraphState> {
|
||||
}
|
||||
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<GraphProps, GraphState> {
|
||||
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<GraphProps, GraphState> {
|
||||
</div>
|
||||
)}
|
||||
<div id={id} className="explore-graph" style={{ height }} />
|
||||
<Legend data={data} />
|
||||
<Legend data={data} hiddenSeries={hiddenSeries} onToggleSeries={this.onToggleSeries} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -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);
|
||||
|
||||
export default class Legend extends PureComponent<any, any> {
|
||||
render() {
|
||||
const { className = '', data } = this.props;
|
||||
const items = data || [];
|
||||
const { hidden, series } = this.props;
|
||||
const seriesClasses = classNames({
|
||||
'graph-legend-series-hidden': hidden,
|
||||
});
|
||||
return (
|
||||
<div className={`${className} graph-legend ps`}>
|
||||
{items.map(series => <LegendItem key={series.id} series={series} />)}
|
||||
<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);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { data, hiddenSeries } = this.props;
|
||||
const items = data || [];
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
@ -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
public/app/features/explore/utils/set.test.ts
Normal file
52
public/app/features/explore/utils/set.test.ts
Normal file
@ -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
public/app/features/explore/utils/set.ts
Normal file
35
public/app/features/explore/utils/set.ts
Normal file
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user