Commit c7dc557e by Michael Huynh

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
parent 0b3e5ec4
...@@ -13,6 +13,7 @@ import * as dateMath from 'app/core/utils/datemath'; ...@@ -13,6 +13,7 @@ import * as dateMath from 'app/core/utils/datemath';
import TimeSeries from 'app/core/time_series2'; import TimeSeries from 'app/core/time_series2';
import Legend from './Legend'; import Legend from './Legend';
import { equal, intersect } from './utils/set';
const MAX_NUMBER_OF_TIME_SERIES = 20; const MAX_NUMBER_OF_TIME_SERIES = 20;
...@@ -85,13 +86,20 @@ interface GraphProps { ...@@ -85,13 +86,20 @@ interface GraphProps {
} }
interface GraphState { 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; showAllTimeSeries: boolean;
} }
export class Graph extends PureComponent<GraphProps, GraphState> { export class Graph extends PureComponent<GraphProps, GraphState> {
$el: any; $el: any;
dynamicOptions = null;
state = { state = {
hiddenSeries: new Set(),
showAllTimeSeries: false, showAllTimeSeries: false,
}; };
...@@ -107,13 +115,14 @@ export class Graph extends PureComponent<GraphProps, GraphState> { ...@@ -107,13 +115,14 @@ export class Graph extends PureComponent<GraphProps, GraphState> {
this.$el.bind('plotselected', this.onPlotSelected); this.$el.bind('plotselected', this.onPlotSelected);
} }
componentDidUpdate(prevProps: GraphProps) { componentDidUpdate(prevProps: GraphProps, prevState: GraphState) {
if ( if (
prevProps.data !== this.props.data || prevProps.data !== this.props.data ||
prevProps.range !== this.props.range || prevProps.range !== this.props.range ||
prevProps.split !== this.props.split || prevProps.split !== this.props.split ||
prevProps.height !== this.props.height || 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(); this.draw();
} }
...@@ -133,6 +142,31 @@ export class Graph extends PureComponent<GraphProps, GraphState> { ...@@ -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 = () => { onShowAllTimeSeries = () => {
this.setState( this.setState(
{ {
...@@ -142,52 +176,65 @@ export class Graph extends PureComponent<GraphProps, GraphState> { ...@@ -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() { draw() {
const { range, size, userOptions = {} } = this.props; const { userOptions = {} } = this.props;
const { hiddenSeries } = this.state;
const data = this.getGraphData(); const data = this.getGraphData();
const $el = $(`#${this.props.id}`); const $el = $(`#${this.props.id}`);
let series = [{ data: [[0, 0]] }]; let series = [{ data: [[0, 0]] }];
if (data && data.length > 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, color: ts.color,
label: ts.label, label: ts.label,
data: ts.getFlotPairs('null'), data: ts.getFlotPairs('null'),
})); }));
} }
const ticks = (size.width || 0) / 100; this.dynamicOptions = this.getDynamicOptions();
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),
},
};
const options = { const options = {
...FLOT_OPTIONS, ...FLOT_OPTIONS,
...dynamicOptions, ...this.dynamicOptions,
...userOptions, ...userOptions,
}; };
$.plot($el, series, options); $.plot($el, series, options);
} }
render() { render() {
const { height = '100px', id = 'graph' } = this.props; const { height = '100px', id = 'graph' } = this.props;
const { hiddenSeries } = this.state;
const data = this.getGraphData(); const data = this.getGraphData();
return ( return (
...@@ -204,7 +251,7 @@ export class Graph extends PureComponent<GraphProps, GraphState> { ...@@ -204,7 +251,7 @@ export class Graph extends PureComponent<GraphProps, GraphState> {
</div> </div>
)} )}
<div id={id} className="explore-graph" style={{ height }} /> <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 }) => ( interface LegendProps {
<div className="graph-legend-series"> data: TimeSeries[];
<div className="graph-legend-icon"> hiddenSeries: Set<string>;
<i className="fa fa-minus pointer" style={{ color: series.color }} /> onToggleSeries?: (series: TimeSeries, exclusive: boolean) => void;
</div> }
<a className="graph-legend-alias pointer" title={series.alias}>
{series.alias} interface LegendItemProps {
</a> hidden: boolean;
</div> 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() { render() {
const { className = '', data } = this.props; const { data, hiddenSeries } = this.props;
const items = data || []; const items = data || [];
return ( return (
<div className={`${className} graph-legend ps`}> <div className="graph-legend ps">
{items.map(series => <LegendItem key={series.id} series={series} />)} {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> </div>
); );
} }
......
...@@ -453,6 +453,8 @@ exports[`Render should render component 1`] = ` ...@@ -453,6 +453,8 @@ exports[`Render should render component 1`] = `
}, },
] ]
} }
hiddenSeries={Set {}}
onToggleSeries={[Function]}
/> />
</Fragment> </Fragment>
`; `;
...@@ -947,6 +949,8 @@ exports[`Render should render component with disclaimer 1`] = ` ...@@ -947,6 +949,8 @@ exports[`Render should render component with disclaimer 1`] = `
}, },
] ]
} }
hiddenSeries={Set {}}
onToggleSeries={[Function]}
/> />
</Fragment> </Fragment>
`; `;
...@@ -964,6 +968,8 @@ exports[`Render should show query return no time series 1`] = ` ...@@ -964,6 +968,8 @@ exports[`Render should show query return no time series 1`] = `
/> />
<Legend <Legend
data={Array []} data={Array []}
hiddenSeries={Set {}}
onToggleSeries={[Function]}
/> />
</Fragment> </Fragment>
`; `;
/**
* 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;
}
}
}
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