diff --git a/src/components/legend.tsx b/src/components/legend.tsx index 02c33f7ff7..c5b7526c0b 100644 --- a/src/components/legend.tsx +++ b/src/components/legend.tsx @@ -86,8 +86,14 @@ class LegendComponent extends React.Component { responsive={false} > {legendItems.map((item, index) => { + const legendItemProps = { + key: index, + className: 'euiChartLegendList__item', + onMouseOver: this.onLegendItemMouseover(index), + }; + return ( - + ); @@ -97,6 +103,10 @@ class LegendComponent extends React.Component { ); } + + private onLegendItemMouseover = (legendItemIndex: number) => () => { + this.props.chartStore!.updateHighlightedLegendItem(legendItemIndex); + } } function LegendElement({ color, label }: Partial) { return ( diff --git a/src/components/react_canvas/bar_geometries.tsx b/src/components/react_canvas/bar_geometries.tsx index cdfb5d7cb5..65baf07164 100644 --- a/src/components/react_canvas/bar_geometries.tsx +++ b/src/components/react_canvas/bar_geometries.tsx @@ -3,7 +3,9 @@ import { IAction } from 'mobx'; import React from 'react'; import { Group, Rect } from 'react-konva'; import { animated, Spring } from 'react-spring/konva'; +import { LegendItem } from '../../lib/series/legend'; import { BarGeometry, GeometryValue } from '../../lib/series/rendering'; +import { belongsToDataSeries } from '../../lib/series/series_utils'; import { ElementClickListener, TooltipData } from '../../state/chart_state'; interface BarGeometriesDataProps { @@ -12,6 +14,7 @@ interface BarGeometriesDataProps { onElementClick?: ElementClickListener; onElementOver: ((tooltip: TooltipData) => void) & IAction; onElementOut: (() => void) & IAction; + highlightedLegendItem: LegendItem | null; } interface BarGeometriesDataState { overBar?: BarGeometry; @@ -19,7 +22,7 @@ interface BarGeometriesDataState { export class BarGeometries extends React.PureComponent< BarGeometriesDataProps, BarGeometriesDataState -> { + > { static defaultProps: Partial = { animated: false, }; @@ -70,14 +73,34 @@ export class BarGeometries extends React.PureComponent< }); onElementOut(); } + + private computeBarOpacity = (bar: BarGeometry, overBar: BarGeometry | undefined): number => { + const { highlightedLegendItem } = this.props; + + // There are two elements that might be hovered over that could affect this: + // a specific bar element or a legend item; thus, we handle these states as mutually exclusive. + if (overBar) { + if (overBar !== bar) { + return 0.6; + } + return 1; + } else if (highlightedLegendItem != null) { + const isPartOfHighlightedSeries = belongsToDataSeries(bar.value, highlightedLegendItem.value); + + if (isPartOfHighlightedSeries) { + return 1; + } + + return 0.6; + } + return 1; + } + private renderBarGeoms = (bars: BarGeometry[]): JSX.Element[] => { const { overBar } = this.state; return bars.map((bar, i) => { const { x, y, width, height, color, value } = bar; - let opacity = 1; - if (overBar && overBar !== bar) { - opacity = 0.6; - } + const opacity = this.computeBarOpacity(bar, overBar); if (this.props.animated) { return ( diff --git a/src/components/react_canvas/reactive_chart.tsx b/src/components/react_canvas/reactive_chart.tsx index eb0a329717..51e0a1dea1 100644 --- a/src/components/react_canvas/reactive_chart.tsx +++ b/src/components/react_canvas/reactive_chart.tsx @@ -67,10 +67,14 @@ class Chart extends React.Component { onOverElement, onOutElement, onElementClickListener, + highlightedLegendItemIndex, + legendItems, } = this.props.chartStore!; if (!geometries) { return; } + const highlightedLegendItem = highlightedLegendItemIndex ? legendItems[highlightedLegendItemIndex] : null; + return ( { onElementOver={onOverElement} onElementOut={onOutElement} onElementClick={onElementClickListener} + highlightedLegendItem={highlightedLegendItem} /> ); } diff --git a/src/lib/series/series_utils.test.ts b/src/lib/series/series_utils.test.ts new file mode 100644 index 0000000000..ac97156731 --- /dev/null +++ b/src/lib/series/series_utils.test.ts @@ -0,0 +1,47 @@ +import { getSpecId } from '../utils/ids'; +import { GeometryValue } from './rendering'; +import { DataSeriesColorsValues } from './series'; +import { belongsToDataSeries, isEqualSeriesKey } from './series_utils'; + +describe('Series utility functions', () => { + test('can compare series keys for identity', () => { + const seriesKeyA = ['a', 'b', 'c']; + const seriesKeyB = ['a', 'b', 'c']; + const seriesKeyC = ['a', 'b', 'd']; + const seriesKeyD = ['d']; + const seriesKeyE = ['b', 'a', 'c']; + + expect(isEqualSeriesKey(seriesKeyA, seriesKeyB)).toBe(true); + expect(isEqualSeriesKey(seriesKeyB, seriesKeyC)).toBe(false); + expect(isEqualSeriesKey(seriesKeyA, seriesKeyD)).toBe(false); + expect(isEqualSeriesKey(seriesKeyA, seriesKeyE)).toBe(false); + expect(isEqualSeriesKey(seriesKeyA, [])).toBe(false); + }); + + test('can determine if a geometry value belongs to a data series', () => { + const geometryValueA: GeometryValue = { + specId: getSpecId('a'), + datum: null, + seriesKey: ['a', 'b', 'c'], + }; + + const dataSeriesValuesA: DataSeriesColorsValues = { + specId: getSpecId('a'), + colorValues: ['a', 'b', 'c'], + }; + + const dataSeriesValuesB: DataSeriesColorsValues = { + specId: getSpecId('b'), + colorValues: ['a', 'b', 'c'], + }; + + const dataSeriesValuesC: DataSeriesColorsValues = { + specId: getSpecId('a'), + colorValues: ['a', 'b', 'd'], + }; + + expect(belongsToDataSeries(geometryValueA, dataSeriesValuesA)).toBe(true); + expect(belongsToDataSeries(geometryValueA, dataSeriesValuesB)).toBe(false); + expect(belongsToDataSeries(geometryValueA, dataSeriesValuesC)).toBe(false); + }); +}); diff --git a/src/lib/series/series_utils.ts b/src/lib/series/series_utils.ts new file mode 100644 index 0000000000..dbea45cbc6 --- /dev/null +++ b/src/lib/series/series_utils.ts @@ -0,0 +1,31 @@ +import { GeometryValue } from './rendering'; +import { DataSeriesColorsValues } from './series'; + +export function isEqualSeriesKey(a: any[], b: any[]): boolean { + if (a.length !== b.length) { + return false; + } + + let ret = true; + + a.forEach((aVal: any, idx: number) => { + if (aVal !== b[idx]) { + ret = false; + } + }); + + return ret; +} + +export function belongsToDataSeries(geometryValue: GeometryValue, dataSeriesValues: DataSeriesColorsValues): boolean { + const legendItemSeriesKey = dataSeriesValues.colorValues; + const legendItemSpecId = dataSeriesValues.specId; + + const geometrySeriesKey = geometryValue.seriesKey; + const geometrySpecId = geometryValue.specId; + + const hasSameSpecId = legendItemSpecId === geometrySpecId; + const hasSameSeriesKey = isEqualSeriesKey(legendItemSeriesKey, geometrySeriesKey); + + return hasSameSpecId && hasSameSeriesKey; +} diff --git a/src/state/chart_state.ts b/src/state/chart_state.ts index a1fa2d2512..19f386a64d 100644 --- a/src/state/chart_state.ts +++ b/src/state/chart_state.ts @@ -123,6 +123,7 @@ export class ChartStore { yScales?: Map; legendItems: LegendItem[] = []; + highlightedLegendItemIndex: number | null = null; tooltipData = observable.box | null>(null); tooltipPosition = observable.box<{ x: number; y: number } | null>(); @@ -229,6 +230,13 @@ export class ChartStore { return this.xScale.type !== ScaleType.Ordinal && Boolean(this.onBrushEndListener); } + updateHighlightedLegendItem(legendItemIndex: number) { + if (legendItemIndex !== this.highlightedLegendItemIndex) { + this.highlightedLegendItemIndex = legendItemIndex; + this.computeChart(); + } + } + updateParentDimensions(width: number, height: number, top: number, left: number) { let isChanged = false; if (width !== this.parentDimensions.width) {