diff --git a/src/components/_legend.scss b/src/components/_legend.scss index 43b60c3312..1313a186a3 100644 --- a/src/components/_legend.scss +++ b/src/components/_legend.scss @@ -48,7 +48,7 @@ $euiChartLegendMaxHeight: $euiSize * 4 + $euiSize; top: 0; bottom: 0; left: 0; - width: $euiChartLegendMaxWidth; + width: $euiChartLegendMaxWidth; order: 1; .euiChartLegend__listItem { min-width: 100%; @@ -58,7 +58,7 @@ $euiChartLegendMaxHeight: $euiSize * 4 + $euiSize; top: 0; bottom: 0; right: 0; - width: $euiChartLegendMaxWidth; + width: $euiChartLegendMaxWidth; .euiChartLegend__listItem { min-width: 100%; } @@ -93,4 +93,9 @@ $euiChartLegendMaxHeight: $euiSize * 4 + $euiSize; .euiChartLegendListItem__title { width: $euiChartLegendMaxWidth - 4 * $euiSize; max-width: $euiChartLegendMaxWidth - 4 * $euiSize; +} +.euiChartLegendList__item { + &:hover { + text-decoration: underline; + } } \ No newline at end of file diff --git a/src/components/legend.tsx b/src/components/legend.tsx index 02c33f7ff7..f4fc9b177f 100644 --- a/src/components/legend.tsx +++ b/src/components/legend.tsx @@ -4,7 +4,6 @@ import { EuiFlexItem, EuiIcon, EuiText, - EuiToolTip, } from '@elastic/eui'; import classNames from 'classnames'; import { inject, observer } from 'mobx-react'; @@ -86,8 +85,15 @@ class LegendComponent extends React.Component { responsive={false} > {legendItems.map((item, index) => { + const legendItemProps = { + key: index, + className: 'euiChartLegendList__item', + onMouseEnter: this.onLegendItemMouseover(index), + onMouseLeave: this.onLegendItemMouseout, + }; + return ( - + ); @@ -97,6 +103,14 @@ class LegendComponent extends React.Component { ); } + + private onLegendItemMouseover = (legendItemIndex: number) => () => { + this.props.chartStore!.onLegendItemOver(legendItemIndex); + } + + private onLegendItemMouseout = () => { + this.props.chartStore!.onLegendItemOut(); + } } function LegendElement({ color, label }: Partial) { return ( @@ -105,13 +119,11 @@ function LegendElement({ color, label }: Partial) { - {label}}> - - - {label} - - - + + + {label} + + ); diff --git a/src/components/react_canvas/area_geometries.tsx b/src/components/react_canvas/area_geometries.tsx index 866bd1b5d0..b0c0a7aef3 100644 --- a/src/components/react_canvas/area_geometries.tsx +++ b/src/components/react_canvas/area_geometries.tsx @@ -3,7 +3,8 @@ import { IAction } from 'mobx'; import React from 'react'; import { Circle, Group, Path } from 'react-konva'; import { animated, Spring } from 'react-spring/konva'; -import { AreaGeometry, GeometryValue, PointGeometry } from '../../lib/series/rendering'; +import { LegendItem } from '../../lib/series/legend'; +import { AreaGeometry, GeometryValue, getGeometryStyle, PointGeometry } from '../../lib/series/rendering'; import { AreaSeriesStyle } from '../../lib/themes/theme'; import { ElementClickListener, TooltipData } from '../../state/chart_state'; @@ -15,6 +16,7 @@ interface AreaGeometriesDataProps { onElementClick?: ElementClickListener; onElementOver: ((tooltip: TooltipData) => void) & IAction; onElementOut: (() => void) & IAction; + highlightedLegendItem: LegendItem | null; } interface AreaGeometriesDataState { overPoint?: PointGeometry; @@ -22,7 +24,7 @@ interface AreaGeometriesDataState { export class AreaGeometries extends React.PureComponent< AreaGeometriesDataProps, AreaGeometriesDataState -> { + > { static defaultProps: Partial = { animated: false, num: 1, @@ -131,10 +133,14 @@ export class AreaGeometries extends React.PureComponent< ); }); } + private renderAreaGeoms = (): JSX.Element[] => { const { areas } = this.props; return areas.map((glyph, i) => { - const { area, color, transform } = glyph; + const { area, color, transform, geometryId } = glyph; + + const geometryStyle = getGeometryStyle(geometryId, this.props.highlightedLegendItem); + if (this.props.animated) { return ( @@ -145,8 +151,9 @@ export class AreaGeometries extends React.PureComponent< data={props.area} fill={color} listening={false} - // areaCap="round" - // areaJoin="round" + {...geometryStyle} + // areaCap="round" + // areaJoin="round" /> )} @@ -159,8 +166,9 @@ export class AreaGeometries extends React.PureComponent< data={area} fill={color} listening={false} - // areaCap="round" - // areaJoin="round" + {...geometryStyle} + // areaCap="round" + // areaJoin="round" /> ); } diff --git a/src/components/react_canvas/bar_geometries.tsx b/src/components/react_canvas/bar_geometries.tsx index cdfb5d7cb5..66afadc51f 100644 --- a/src/components/react_canvas/bar_geometries.tsx +++ b/src/components/react_canvas/bar_geometries.tsx @@ -3,7 +3,8 @@ import { IAction } from 'mobx'; import React from 'react'; import { Group, Rect } from 'react-konva'; import { animated, Spring } from 'react-spring/konva'; -import { BarGeometry, GeometryValue } from '../../lib/series/rendering'; +import { LegendItem } from '../../lib/series/legend'; +import { BarGeometry, GeometryValue, getGeometryStyle } from '../../lib/series/rendering'; import { ElementClickListener, TooltipData } from '../../state/chart_state'; interface BarGeometriesDataProps { @@ -12,6 +13,7 @@ interface BarGeometriesDataProps { onElementClick?: ElementClickListener; onElementOver: ((tooltip: TooltipData) => void) & IAction; onElementOut: (() => void) & IAction; + highlightedLegendItem: LegendItem | null; } interface BarGeometriesDataState { overBar?: BarGeometry; @@ -19,7 +21,7 @@ interface BarGeometriesDataState { export class BarGeometries extends React.PureComponent< BarGeometriesDataProps, BarGeometriesDataState -> { + > { static defaultProps: Partial = { animated: false, }; @@ -70,14 +72,22 @@ export class BarGeometries extends React.PureComponent< }); onElementOut(); } + 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; - } + + // Properties to determine if we need to highlight individual bars depending on hover state + const hasGeometryHover = overBar != null; + const hasHighlight = overBar === bar; + const individualHighlight = { + hasGeometryHover, + hasHighlight, + }; + + const geometryStyle = getGeometryStyle(bar.geometryId, this.props.highlightedLegendItem, individualHighlight); + if (this.props.animated) { return ( @@ -91,11 +101,11 @@ export class BarGeometries extends React.PureComponent< height={props.height} fill={color} strokeWidth={0} - opacity={opacity} perfectDrawEnabled={true} onMouseOver={this.onOverBar(bar)} onMouseLeave={this.onOutBar} onClick={this.onElementClick(value)} + {...geometryStyle} /> )} @@ -111,11 +121,11 @@ export class BarGeometries extends React.PureComponent< height={height} fill={color} strokeWidth={0} - opacity={opacity} perfectDrawEnabled={false} onMouseOver={this.onOverBar(bar)} onMouseLeave={this.onOutBar} onClick={this.onElementClick(bar.value)} + {...geometryStyle} /> ); } diff --git a/src/components/react_canvas/line_geometries.tsx b/src/components/react_canvas/line_geometries.tsx index acf3b37f37..8058c6f1c9 100644 --- a/src/components/react_canvas/line_geometries.tsx +++ b/src/components/react_canvas/line_geometries.tsx @@ -3,7 +3,8 @@ import { IAction } from 'mobx'; import React from 'react'; import { Circle, Group, Path } from 'react-konva'; import { animated, Spring } from 'react-spring/konva'; -import { GeometryValue, LineGeometry, PointGeometry } from '../../lib/series/rendering'; +import { LegendItem } from '../../lib/series/legend'; +import { GeometryValue, getGeometryStyle, LineGeometry, PointGeometry } from '../../lib/series/rendering'; import { LineSeriesStyle } from '../../lib/themes/theme'; import { ElementClickListener, TooltipData } from '../../state/chart_state'; @@ -14,6 +15,7 @@ interface LineGeometriesDataProps { onElementClick?: ElementClickListener; onElementOver: ((tooltip: TooltipData) => void) & IAction; onElementOut: (() => void) & IAction; + highlightedLegendItem: LegendItem | null; } interface LineGeometriesDataState { overPoint?: PointGeometry; @@ -21,7 +23,7 @@ interface LineGeometriesDataState { export class LineGeometries extends React.PureComponent< LineGeometriesDataProps, LineGeometriesDataState -> { + > { static defaultProps: Partial = { animated: false, }; @@ -129,10 +131,14 @@ export class LineGeometries extends React.PureComponent< ); }); } + private renderLineGeoms = (): JSX.Element[] => { const { style, lines } = this.props; return lines.map((glyph, i) => { - const { line, color, transform } = glyph; + const { line, color, transform, geometryId } = glyph; + + const geometryStyle = getGeometryStyle(geometryId, this.props.highlightedLegendItem); + if (this.props.animated) { return ( @@ -146,6 +152,7 @@ export class LineGeometries extends React.PureComponent< listening={false} lineCap="round" lineJoin="round" + {...geometryStyle} /> )} @@ -161,6 +168,7 @@ export class LineGeometries extends React.PureComponent< listening={false} lineCap="round" lineJoin="round" + {...geometryStyle} /> ); } diff --git a/src/components/react_canvas/reactive_chart.tsx b/src/components/react_canvas/reactive_chart.tsx index eb0a329717..0f1cd7aac3 100644 --- a/src/components/react_canvas/reactive_chart.tsx +++ b/src/components/react_canvas/reactive_chart.tsx @@ -71,6 +71,8 @@ class Chart extends React.Component { if (!geometries) { return; } + const highlightedLegendItem = this.getHighlightedLegendItem(); + return ( { onElementOver={onOverElement} onElementOut={onOutElement} onElementClick={onElementClickListener} + highlightedLegendItem={highlightedLegendItem} /> ); } @@ -93,6 +96,9 @@ class Chart extends React.Component { if (!geometries) { return; } + + const highlightedLegendItem = this.getHighlightedLegendItem(); + return ( { onElementOver={onOverElement} onElementOut={onOutElement} onElementClick={onElementClickListener} + highlightedLegendItem={highlightedLegendItem} /> ); } @@ -116,6 +123,9 @@ class Chart extends React.Component { if (!geometries) { return; } + + const highlightedLegendItem = this.getHighlightedLegendItem(); + return ( { onElementOver={onOverElement} onElementOut={onOutElement} onElementClick={onElementClickListener} + highlightedLegendItem={highlightedLegendItem} /> ); } @@ -353,6 +364,10 @@ class Chart extends React.Component { /> ); } + + private getHighlightedLegendItem = () => { + return this.props.chartStore!.highlightedLegendItem.get(); + } } export const ReactiveChart = inject('chartStore')(observer(Chart)); diff --git a/src/lib/series/rendering.ts b/src/lib/series/rendering.ts index a8d8e11053..bd0e5e7cf1 100644 --- a/src/lib/series/rendering.ts +++ b/src/lib/series/rendering.ts @@ -1,14 +1,26 @@ import { area, line } from 'd3-shape'; +import { DEFAULT_THEME } from '../themes/theme'; import { SpecId } from '../utils/ids'; import { Scale } from '../utils/scales/scales'; import { CurveType, getCurveFactory } from './curves'; +import { LegendItem } from './legend'; import { DataSeriesDatum } from './series'; +import { belongsToDataSeries } from './series_utils'; -export interface GeometryValue { +export interface GeometryId { specId: SpecId; - datum: any; seriesKey: any[]; } + +export interface GeometryValue extends GeometryId { + datum: any; +} + +/** Shared style properties for varies geometries */ +export interface GeometryStyle { + opacity: number; +} + export interface PointGeometry { x: number; y: number; @@ -26,6 +38,7 @@ export interface BarGeometry { height: number; color: string; value: GeometryValue; + geometryId: GeometryId; } export interface LineGeometry { line: string; @@ -35,6 +48,7 @@ export interface LineGeometry { x: number; y: number; }; + geometryId: GeometryId; } export interface AreaGeometry { area: string; @@ -45,6 +59,7 @@ export interface AreaGeometry { x: number; y: number; }; + geometryId: GeometryId; } export function renderPoints( @@ -95,6 +110,10 @@ export function renderBars( datum: datum.datum, seriesKey, }, + geometryId: { + specId, + seriesKey, + }, }; }); } @@ -123,6 +142,10 @@ export function renderLine( x, y, }, + geometryId: { + specId, + seriesKey, + }, }; } @@ -148,5 +171,33 @@ export function renderArea( points: lineGeometry.points, color, transform: lineGeometry.transform, + geometryId: { + specId, + seriesKey, + }, }; } + +export function getGeometryStyle( + geometryId: GeometryId, + highlightedLegendItem: LegendItem | null, + individualHighlight?: { [key: string]: boolean }, +): GeometryStyle { + const { shared } = DEFAULT_THEME.chart.styles; + + if (highlightedLegendItem != null) { + const isPartOfHighlightedSeries = belongsToDataSeries(geometryId, highlightedLegendItem.value); + + return isPartOfHighlightedSeries ? shared.highlighted : shared.unhighlighted; + } + + if (individualHighlight) { + const { hasHighlight, hasGeometryHover } = individualHighlight; + if (!hasGeometryHover) { + return shared.highlighted; + } + return hasHighlight ? shared.highlighted : shared.unhighlighted; + } + + return shared.default; +} diff --git a/src/lib/series/series_utils.test.ts b/src/lib/series/series_utils.test.ts new file mode 100644 index 0000000000..d098eab356 --- /dev/null +++ b/src/lib/series/series_utils.test.ts @@ -0,0 +1,46 @@ +import { getSpecId } from '../utils/ids'; +import { GeometryId } 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 id belongs to a data series', () => { + const geometryIdA: GeometryId = { + specId: getSpecId('a'), + 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(geometryIdA, dataSeriesValuesA)).toBe(true); + expect(belongsToDataSeries(geometryIdA, dataSeriesValuesB)).toBe(false); + expect(belongsToDataSeries(geometryIdA, 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..657266a367 --- /dev/null +++ b/src/lib/series/series_utils.ts @@ -0,0 +1,29 @@ +import { GeometryId } from './rendering'; +import { DataSeriesColorsValues } from './series'; + +export function isEqualSeriesKey(a: any[], b: any[]): boolean { + if (a.length !== b.length) { + return false; + } + + for (let i = 0, l = a.length; i < l; i++) { + if (a[i] !== b[i]) { + return false; + } + } + + return true; +} + +export function belongsToDataSeries(geometryValue: GeometryId, 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/lib/themes/theme.ts b/src/lib/themes/theme.ts index ab1b13779f..34bad0574c 100644 --- a/src/lib/themes/theme.ts +++ b/src/lib/themes/theme.ts @@ -1,3 +1,4 @@ +import { GeometryStyle } from '../series/rendering'; import { Margins } from '../utils/dimensions'; export interface ChartConfig { @@ -11,6 +12,7 @@ export interface ChartConfig { styles: { lineSeries: LineSeriesStyle; areaSeries: AreaSeriesStyle; + shared: { [key: string]: GeometryStyle }; }; } export interface AxisConfig { @@ -91,6 +93,18 @@ export const DEFAULT_GRID_LINE_CONFIG: GridLineConfig = { opacity: 1, }; +export const GEOMETRY_STYLES: { [key: string]: GeometryStyle } = { + default: { + opacity: 1, + }, + highlighted: { + opacity: 1, + }, + unhighlighted: { + opacity: 0.25, + }, +}; + export const DEFAULT_THEME: Theme = { chart: { paddings: { @@ -130,6 +144,7 @@ export const DEFAULT_THEME: Theme = { dataPointsStroke: 'white', dataPointsStrokeWidth: 1, }, + shared: GEOMETRY_STYLES, }, }, scales: { diff --git a/src/specs/settings.tsx b/src/specs/settings.tsx index 4dd3006a7c..19bc95098e 100644 --- a/src/specs/settings.tsx +++ b/src/specs/settings.tsx @@ -7,6 +7,7 @@ import { ChartStore, ElementClickListener, ElementOverListener, + LegendItemListener, } from '../state/chart_state'; interface SettingSpecProps { @@ -22,6 +23,8 @@ interface SettingSpecProps { onElementOver?: ElementOverListener; onElementOut?: () => undefined; onBrushEnd?: BrushEndListener; + onLegendItemOver?: LegendItemListener; + onLegendItemOut?: () => undefined; } function updateChartStore(props: SettingSpecProps) { @@ -37,6 +40,8 @@ function updateChartStore(props: SettingSpecProps) { onElementOver, onElementOut, onBrushEnd, + onLegendItemOver, + onLegendItemOut, debug, } = props; if (!chartStore) { @@ -63,6 +68,12 @@ function updateChartStore(props: SettingSpecProps) { if (onBrushEnd) { chartStore.setOnBrushEndListener(onBrushEnd); } + if (onLegendItemOver) { + chartStore.setOnLegendItemOverListener(onLegendItemOver); + } + if (onLegendItemOut) { + chartStore.setOnLegendItemOutListener(onLegendItemOut); + } } export class SettingsComponent extends PureComponent { diff --git a/src/state/chart_state.ts b/src/state/chart_state.ts index a1fa2d2512..3cb563f377 100644 --- a/src/state/chart_state.ts +++ b/src/state/chart_state.ts @@ -1,4 +1,4 @@ -import { action, observable } from 'mobx'; +import { action, computed, IObservableValue, observable } from 'mobx'; import { AxisLinePosition, AxisTick, @@ -76,6 +76,7 @@ export interface SeriesDomainsAndData { export type ElementClickListener = (value: GeometryValue) => void; export type ElementOverListener = (value: GeometryValue) => void; export type BrushEndListener = (min: number, max: number) => void; +export type LegendItemListener = (dataSeriesIdentifiers: DataSeriesColorsValues | null) => void; // const MAX_ANIMATABLE_GLYPHS = 500; export class ChartStore { @@ -123,6 +124,7 @@ export class ChartStore { yScales?: Map; legendItems: LegendItem[] = []; + highlightedLegendItemIndex: IObservableValue = observable.box(null); tooltipData = observable.box | null>(null); tooltipPosition = observable.box<{ x: number; y: number } | null>(); @@ -133,6 +135,9 @@ export class ChartStore { onElementOutListener?: () => undefined; onBrushEndListener?: BrushEndListener; + onLegendItemOverListener?: LegendItemListener; + onLegendItemOutListener?: () => undefined; + geometries: { points: PointGeometry[]; bars: BarGeometry[]; @@ -186,6 +191,27 @@ export class ChartStore { this.showLegend.set(showLegend); }); + highlightedLegendItem = computed(() => { + const index = this.highlightedLegendItemIndex.get(); + return index == null ? null : this.legendItems[index]; + }); + + onLegendItemOver = action((legendItemIndex: number) => { + this.highlightedLegendItemIndex.set(legendItemIndex); + if (this.onLegendItemOverListener) { + const currentLegendItem = this.highlightedLegendItem.get(); + const listenerData = currentLegendItem ? currentLegendItem.value : null; + this.onLegendItemOverListener(listenerData); + } + }); + + onLegendItemOut = action(() => { + this.highlightedLegendItemIndex.set(null); + if (this.onLegendItemOutListener) { + this.onLegendItemOutListener(); + } + }); + setOnElementClickListener(listener: ElementClickListener) { this.onElementClickListener = listener; } @@ -198,6 +224,12 @@ export class ChartStore { setOnBrushEndListener(listener: BrushEndListener) { this.onBrushEndListener = listener; } + setOnLegendItemOverListener(listener: LegendItemListener) { + this.onLegendItemOverListener = listener; + } + setOnLegendItemOutListener(listener: () => undefined) { + this.onLegendItemOutListener = listener; + } removeElementClickListener() { this.onElementClickListener = undefined; } @@ -207,6 +239,12 @@ export class ChartStore { removeElementOutListener() { this.onElementOutListener = undefined; } + removeOnLegendItemOverListener() { + this.onLegendItemOverListener = undefined; + } + removeOnLegendItemOutListener() { + this.onLegendItemOutListener = undefined; + } onBrushEnd(start: Point, end: Point) { if (!this.onBrushEndListener) { return; diff --git a/stories/interactions.tsx b/stories/interactions.tsx index 1b404c953d..5d9752bf6e 100644 --- a/stories/interactions.tsx +++ b/stories/interactions.tsx @@ -14,6 +14,8 @@ import { import { boolean } from '@storybook/addon-knobs'; import { DateTime } from 'luxon'; +import { CurveType } from '../src/lib/series/curves'; +import * as TestDatasets from '../src/lib/series/utils/test_dataset'; import { AreaSeries, LineSeries } from '../src/specs'; import { niceTimeFormatter } from '../src/utils/data/formatters'; @@ -23,6 +25,11 @@ const onElementListeners = { onElementOut: action('onElementOut'), }; +const onLegendItemListeners = { + onLegendItemOver: action('onLegendItemOver'), + onLegendItemOut: action('onLegendItemOut'), +}; + storiesOf('Interactions', module) .add('bar clicks and hovers', () => { return ( @@ -111,7 +118,196 @@ storiesOf('Interactions', module) ); }) - .add('click/hovers on legend items (TO DO)', () =>

TO DO

) + .add('click/hovers on legend items [bar chart] (TO DO click)', () => { + return ( + + + + Number(d).toFixed(2)} + /> + + + + ); + }) + .add('click/hovers on legend items [area chart] (TO DO click)', () => { + return ( + + + + Number(d).toFixed(2)} + /> + + + + ); + }) + .add('click/hovers on legend items [line chart] (TO DO click)', () => { + return ( + + + + Number(d).toFixed(2)} + /> + + + + + + + + + ); + }) + .add('click/hovers on legend items [mixed chart] (TO DO click)', () => { + return ( + + + + Number(d).toFixed(2)} + /> + + + + + ); + }) .add('brush selection tool on linear', () => { return (