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
This commit is contained in:
Michael Huynh 2018-11-15 21:56:52 +08:00
parent 0b3e5ec4a7
commit c7dc557e91
No known key found for this signature in database
GPG Key ID: 760127DAE4EDD351
4 changed files with 174 additions and 45 deletions

View File

@ -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} />
</>
);
}

View File

@ -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>
);
}

View File

@ -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>
`;

View File

@ -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;
}
}
}