Commit 1125ca4d by David Committed by GitHub

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

Add ability to toggle visibility of graph series in explore section
parents c3d64e03 a333c31e
......@@ -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} />
</>
);
}
......
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>
);
}
......
......@@ -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>
`;
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]));
});
});
/**
* 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);
}
}
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment