mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
parent
0b3e5ec4a7
commit
c7dc557e91
@ -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>
|
||||
`;
|
||||
|
34
public/app/features/explore/utils/set.ts
Normal file
34
public/app/features/explore/utils/set.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user