diff --git a/src/components/_legend.scss b/src/components/_legend.scss index 2cb4c6fe89..f91ba35c19 100644 --- a/src/components/_legend.scss +++ b/src/components/_legend.scss @@ -90,14 +90,24 @@ $elasticChartsLegendMaxHeight: $euiSize * 4; } .elasticChartsLegendList__item { + cursor: pointer; + &:hover { - text-decoration: underline; + .elasticChartsLegendListItem__title { + text-decoration: underline; + } } } .elasticChartsLegendListItem__title { width: $elasticChartsLegendMaxWidth - 4 * $euiSize; max-width: $elasticChartsLegendMaxWidth - 4 * $euiSize; + + &.elasticChartsLegendListItem__title--selected { + .elasticChartsLegendListItem__title { + text-decoration: underline; + } + } } .elasticChartsLegend__toggle { diff --git a/src/components/legend.tsx b/src/components/legend.tsx index 6f44774edc..3dbc79320e 100644 --- a/src/components/legend.tsx +++ b/src/components/legend.tsx @@ -1,10 +1,14 @@ -import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiText } from '@elastic/eui'; +import { + EuiFlexGroup, + EuiFlexItem, +} from '@elastic/eui'; import classNames from 'classnames'; import { inject, observer } from 'mobx-react'; import React from 'react'; import { isVertical } from '../lib/axes/axis_utils'; import { LegendItem } from '../lib/series/legend'; import { ChartStore } from '../state/chart_state'; +import { LegendElement } from './legend_element'; interface ReactiveChartProps { chartStore?: ChartStore; // FIX until we find a better way on ts mobx @@ -74,9 +78,11 @@ class LegendComponent extends React.Component { onMouseLeave: this.onLegendItemMouseout, }; + const { color, label, isVisible } = item; + return ( - + {this.renderLegendElement({ color, label, isVisible }, index)} ); })} @@ -93,22 +99,12 @@ class LegendComponent extends React.Component { private onLegendItemMouseout = () => { this.props.chartStore!.onLegendItemOut(); } -} -function LegendElement({ color, label }: Partial) { - return ( - - - - - - - - {label} - - - - - ); + + private renderLegendElement = ({ color, label, isVisible }: Partial, legendItemIndex: number) => { + const props = { color, label, isVisible, index: legendItemIndex }; + + return ; + } } export const Legend = inject('chartStore')(observer(LegendComponent)); diff --git a/src/components/legend_element.tsx b/src/components/legend_element.tsx new file mode 100644 index 0000000000..39a900f120 --- /dev/null +++ b/src/components/legend_element.tsx @@ -0,0 +1,168 @@ +import { + EuiButtonIcon, + // TODO: remove ts-ignore below once typings file is included in eui for color picker + // @ts-ignore + EuiColorPicker, + EuiContextMenuPanel, + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiPopover, + EuiText, +} from '@elastic/eui'; +import classNames from 'classnames'; +import { inject, observer } from 'mobx-react'; +import React from 'react'; + +import { ChartStore } from '../state/chart_state'; + +interface LegendElementProps { + chartStore?: ChartStore; // FIX until we find a better way on ts mobx + index: number; + color: string | undefined; + label: string | undefined; + isVisible?: boolean; +} + +interface LegendElementState { + isColorPickerOpen: boolean; +} + +class LegendElementComponent extends React.Component { + static displayName = 'LegendElement'; + + constructor(props: LegendElementProps) { + super(props); + this.state = { + isColorPickerOpen: false, + }; + } + + closeColorPicker = () => { + this.setState({ + isColorPickerOpen: false, + }); + } + + toggleColorPicker = () => { + this.setState({ + isColorPickerOpen: !this.state.isColorPickerOpen, + }); + } + + render() { + const legendItemIndex = this.props.index; + const { color, label, isVisible } = this.props; + + const onTitleClick = this.onLegendTitleClick(legendItemIndex); + + const isSelected = legendItemIndex === this.props.chartStore!.selectedLegendItemIndex.get(); + const titleClassNames = classNames({ + ['elasticChartsLegendListItem__title--selected']: isSelected, + }, 'elasticChartsLegendListItem__title'); + + const colorDotProps = { + color, + onClick: this.toggleColorPicker, + }; + + const colorDot = ; + + return ( + + + + + + + + + + {this.renderVisibilityButton(legendItemIndex, isVisible)} + + + + {label} + ) + } + isOpen={isSelected} + closePopover={this.onLegendItemPanelClose} + panelPaddingSize="s" + anchorPosition="downCenter" + > + + + + {this.renderPlusButton()} + + + {this.renderMinusButton()} + + + + + + + ); + } + + private onLegendTitleClick = (legendItemIndex: number) => () => { + this.props.chartStore!.onLegendItemClick(legendItemIndex); + } + + private onLegendItemPanelClose = () => { + // tslint:disable-next-line:no-console + console.log('close'); + } + + private onColorPickerChange = (legendItemIndex: number) => (color: string) => { + this.props.chartStore!.setSeriesColor(legendItemIndex, color); + } + + private renderPlusButton = () => { + return ( + ); + } + + private renderMinusButton = () => { + return ( + ); + } + + private onVisibilityClick = (legendItemIndex: number) => (event: React.MouseEvent) => { + if (event.shiftKey) { + this.props.chartStore!.toggleSingleSeries(legendItemIndex); + } else { + this.props.chartStore!.toggleSeriesVisibility(legendItemIndex); + } + } + + private renderVisibilityButton = (legendItemIndex: number, isVisible: boolean = true) => { + const iconType = isVisible ? 'eye' : 'eyeClosed'; + + return ; + } +} + +export const LegendElement = inject('chartStore')(observer(LegendElementComponent)); diff --git a/src/components/react_canvas/line_geometries.tsx b/src/components/react_canvas/line_geometries.tsx index 9f408e29cd..32fc76649e 100644 --- a/src/components/react_canvas/line_geometries.tsx +++ b/src/components/react_canvas/line_geometries.tsx @@ -29,7 +29,7 @@ interface LineGeometriesDataState { export class LineGeometries extends React.PureComponent< LineGeometriesDataProps, LineGeometriesDataState -> { + > { static defaultProps: Partial = { animated: false, }; @@ -41,6 +41,7 @@ export class LineGeometries extends React.PureComponent< overPoint: undefined, }; } + render() { return ( @@ -153,11 +154,12 @@ export class LineGeometries extends React.PureComponent< if (this.props.animated) { return ( - - {(props: { line: string }) => ( + + {(props: { opacity: number }) => ( { seriesColor.set('colorSeries1a', colorValues1a); const legend = computeLegend(seriesColor, seriesColorMap, specs, 'violet'); const expected = [ - { color: 'red', label: 'spec1', value: { colorValues: [], specId: 'spec1' } }, + { color: 'red', label: 'spec1', value: { colorValues: [], specId: 'spec1' }, isVisible: true }, ]; expect(legend).toEqual(expected); }); @@ -69,8 +69,8 @@ describe('Legends', () => { seriesColor.set('colorSeries1b', colorValues1b); const legend = computeLegend(seriesColor, seriesColorMap, specs, 'violet'); const expected = [ - { color: 'red', label: 'spec1', value: { colorValues: [], specId: 'spec1' } }, - { color: 'blue', label: 'a - b', value: { colorValues: ['a', 'b'], specId: 'spec1' } }, + { color: 'red', label: 'spec1', value: { colorValues: [], specId: 'spec1' }, isVisible: true }, + { color: 'blue', label: 'a - b', value: { colorValues: ['a', 'b'], specId: 'spec1' }, isVisible: true }, ]; expect(legend).toEqual(expected); }); @@ -79,8 +79,8 @@ describe('Legends', () => { seriesColor.set('colorSeries2a', colorValues2a); const legend = computeLegend(seriesColor, seriesColorMap, specs, 'violet'); const expected = [ - { color: 'red', label: 'spec1', value: { colorValues: [], specId: 'spec1' } }, - { color: 'green', label: 'spec2', value: { colorValues: [], specId: 'spec2' } }, + { color: 'red', label: 'spec1', value: { colorValues: [], specId: 'spec1' }, isVisible: true }, + { color: 'green', label: 'spec2', value: { colorValues: [], specId: 'spec2' }, isVisible: true }, ]; expect(legend).toEqual(expected); }); @@ -94,8 +94,37 @@ describe('Legends', () => { const emptyColorMap = new Map(); const legend = computeLegend(seriesColor, emptyColorMap, specs, 'violet'); const expected = [ - { color: 'violet', label: 'spec1', value: { colorValues: [], specId: 'spec1' } }, + { color: 'violet', label: 'spec1', value: { colorValues: [], specId: 'spec1' }, isVisible: true }, ]; expect(legend).toEqual(expected); }); + it('sets all series legend items to visible when selectedDataSeries is null', () => { + seriesColor.set('colorSeries1a', colorValues1a); + seriesColor.set('colorSeries1b', colorValues1b); + seriesColor.set('colorSeries2a', colorValues2a); + seriesColor.set('colorSeries2b', colorValues2b); + + const emptyColorMap = new Map(); + const selectedDataSeries = null; + + const legend = computeLegend(seriesColor, emptyColorMap, specs, 'violet', selectedDataSeries); + + const visibility = legend.map((item) => item.isVisible); + + expect(visibility).toEqual([true, true, true, true]); + }); + it('selectively sets series to visible when there are selectedDataSeries items', () => { + seriesColor.set('colorSeries1a', colorValues1a); + seriesColor.set('colorSeries1b', colorValues1b); + seriesColor.set('colorSeries2a', colorValues2a); + seriesColor.set('colorSeries2b', colorValues2b); + + const emptyColorMap = new Map(); + const selectedDataSeries = [colorValues1a, colorValues1b]; + + const legend = computeLegend(seriesColor, emptyColorMap, specs, 'violet', selectedDataSeries); + + const visibility = legend.map((item) => item.isVisible); + expect(visibility).toEqual([true, true, false, false]); + }); }); diff --git a/src/lib/series/legend.ts b/src/lib/series/legend.ts index 39460c6cb6..c8f8f7bd50 100644 --- a/src/lib/series/legend.ts +++ b/src/lib/series/legend.ts @@ -1,36 +1,59 @@ +import { findSelectedDataSeries } from '../../state/utils'; import { SpecId } from '../utils/ids'; import { DataSeriesColorsValues } from './series'; import { BasicSeriesSpec } from './specs'; + export interface LegendItem { color: string; label: string; value: DataSeriesColorsValues; + isVisible?: boolean; } export function computeLegend( seriesColor: Map, seriesColorMap: Map, specs: Map, defaultColor: string, + selectedDataSeries?: DataSeriesColorsValues[] | null, ): LegendItem[] { const legendItems: LegendItem[] = []; seriesColor.forEach((series, key) => { + const spec = specs.get(series.specId); + const color = seriesColorMap.get(key) || defaultColor; - let label = ''; + const hasSingleSeries = seriesColor.size === 1; + const label = getSeriesColorLabel(series, hasSingleSeries, spec); + const isVisible = selectedDataSeries ? findSelectedDataSeries(selectedDataSeries, series) > -1 : true; - if (seriesColor.size === 1 || series.colorValues.length === 0 || !series.colorValues[0]) { - const spec = specs.get(series.specId); - if (!spec) { - return; - } - label = `${spec.id}`; - } else { - label = series.colorValues.join(' - '); + if (!label) { + return; } + legendItems.push({ color, label, value: series, + isVisible, }); }); return legendItems; } + +export function getSeriesColorLabel( + series: DataSeriesColorsValues, + hasSingleSeries: boolean, + spec: BasicSeriesSpec | undefined, +): string | undefined { + let label = ''; + + if (hasSingleSeries || series.colorValues.length === 0 || !series.colorValues[0]) { + if (!spec) { + return; + } + label = `${spec.id}`; + } else { + label = series.colorValues.join(' - '); + } + + return label; +} diff --git a/src/lib/series/series.test.ts b/src/lib/series/series.test.ts index ca0b420fe6..961a1be4b0 100644 --- a/src/lib/series/series.test.ts +++ b/src/lib/series/series.test.ts @@ -1,8 +1,11 @@ +import { ColorConfig } from '../themes/theme'; import { getGroupId, getSpecId, SpecId } from '../utils/ids'; import { ScaleType } from '../utils/scales/scales'; import { + DataSeriesColorsValues, formatStackedDataSeriesValues, getFormattedDataseries, + getSeriesColorMap, getSplittedSeries, RawDataSeries, splitSeries, @@ -231,4 +234,80 @@ describe('Series', () => { ); expect(stackedDataSeries.stacked).toMatchSnapshot(); }); + test('should get series color map', () => { + const spec1: BasicSeriesSpec = { + id: getSpecId('spec1'), + groupId: getGroupId('group'), + seriesType: 'line', + yScaleType: ScaleType.Log, + xScaleType: ScaleType.Linear, + xAccessor: 'x', + yAccessors: ['y'], + yScaleToDataExtent: false, + data: TestDataset.BARCHART_1Y0G, + }; + + const specs = new Map(); + specs.set(spec1.id, spec1); + + const dataSeriesValuesA: DataSeriesColorsValues = { + specId: getSpecId('spec1'), + colorValues: ['a', 'b', 'c'], + }; + + const chartColors: ColorConfig = { + vizColors: ['elastic_charts_c1', 'elastic_charts_c2'], + defaultVizColor: 'elastic_charts', + }; + + const seriesColors = new Map(); + seriesColors.set('spec1', dataSeriesValuesA); + + const emptyCustomColors = new Map(); + + const defaultColorMap = getSeriesColorMap(seriesColors, chartColors, emptyCustomColors, specs); + const expectedDefaultColorMap = new Map(); + expectedDefaultColorMap.set('spec1', 'elastic_charts_c1'); + expect(defaultColorMap).toEqual(expectedDefaultColorMap); + + const customColors: Map = new Map(); + customColors.set('spec1', 'custom_color'); + + const customizedColorMap = getSeriesColorMap(seriesColors, chartColors, customColors, specs); + const expectedCustomizedColorMap = new Map(); + expectedCustomizedColorMap.set('spec1', 'custom_color'); + expect(customizedColorMap).toEqual(expectedCustomizedColorMap); + }); + test('should only include selectedDataSeries when splitting series if selectedDataSeries is defined', () => { + const seriesSpecs = new Map(); + const specId = getSpecId('splitSpec'); + + const splitSpec: BasicSeriesSpec = { + id: specId, + groupId: getGroupId('group'), + seriesType: 'line', + yScaleType: ScaleType.Log, + xScaleType: ScaleType.Linear, + xAccessor: 'x', + yAccessors: ['y1', 'y2'], + stackAccessors: ['x'], + yScaleToDataExtent: false, + data: TestDataset.BARCHART_2Y0G, + }; + + seriesSpecs.set(splitSpec.id, splitSpec); + + const allSeries = getSplittedSeries(seriesSpecs, null); + expect(allSeries.splittedSeries.get(specId)!.length).toBe(2); + + const emptySplit = getSplittedSeries(seriesSpecs, []); + expect(emptySplit.splittedSeries.get(specId)!.length).toBe(0); + + const selectedDataSeries: DataSeriesColorsValues[] = [{ + specId, + colorValues: ['y1'], + }]; + const subsetSplit = getSplittedSeries(seriesSpecs, selectedDataSeries); + expect(subsetSplit.splittedSeries.get(specId)!.length).toBe(1); + }); }); diff --git a/src/lib/series/series.ts b/src/lib/series/series.ts index d06e856b17..6baf74f7b1 100644 --- a/src/lib/series/series.ts +++ b/src/lib/series/series.ts @@ -1,7 +1,9 @@ +import { findSelectedDataSeries } from '../../state/utils'; import { ColorConfig } from '../themes/theme'; import { Accessor } from '../utils/accessor'; import { GroupId, SpecId } from '../utils/ids'; import { splitSpecsByGroupId, YBasicSeriesSpec } from './domains/y_domain'; +import { getSeriesColorLabel } from './legend'; import { BasicSeriesSpec, Datum, SeriesAccessors } from './specs'; export interface RawDataSeriesDatum { @@ -341,6 +343,7 @@ export function formatStackedDataSeriesValues( export function getSplittedSeries( seriesSpecs: Map, + selectedDataSeries?: DataSeriesColorsValues[] | null, ): { splittedSeries: Map; seriesColors: Map; @@ -349,15 +352,31 @@ export function getSplittedSeries( const splittedSeries = new Map(); const seriesColors = new Map(); const xValues: Set = new Set(); + for (const [specId, spec] of seriesSpecs) { const dataSeries = splitSeries(spec.data, spec, specId); - splittedSeries.set(specId, dataSeries.rawDataSeries); + + let currentRawDataSeries = dataSeries.rawDataSeries; + if (selectedDataSeries) { + currentRawDataSeries = dataSeries.rawDataSeries.filter((series): boolean => { + const seriesValues = { + specId, + colorValues: series.key, + }; + + return findSelectedDataSeries(selectedDataSeries, seriesValues) > -1; + }); + } + + splittedSeries.set(specId, currentRawDataSeries); + dataSeries.colorsValues.forEach((colorValues, key) => { seriesColors.set(key, { specId, colorValues, }); }); + for (const xValue of dataSeries.xValues) { xValues.add(xValue); } @@ -372,13 +391,23 @@ export function getSplittedSeries( export function getSeriesColorMap( seriesColors: Map, chartColors: ColorConfig, + customColors: Map, + specs: Map, ): Map { const seriesColorMap = new Map(); let counter = 0; + seriesColors.forEach((value, seriesColorKey) => { + const spec = specs.get(value.specId); + const hasSingleSeries = seriesColors.size === 1; + const seriesLabel = getSeriesColorLabel(value, hasSingleSeries, spec); + + const color = (seriesLabel && customColors.get(seriesLabel)) || + chartColors.vizColors[counter % chartColors.vizColors.length]; + seriesColorMap.set( seriesColorKey, - chartColors.vizColors[counter % chartColors.vizColors.length], + color, ); counter++; }); diff --git a/src/specs/settings.tsx b/src/specs/settings.tsx index a4411d28f9..fd0543c528 100644 --- a/src/specs/settings.tsx +++ b/src/specs/settings.tsx @@ -26,6 +26,9 @@ interface SettingSpecProps { onBrushEnd?: BrushEndListener; onLegendItemOver?: LegendItemListener; onLegendItemOut?: () => undefined; + onLegendItemClick?: LegendItemListener; + onLegendItemPlusClick?: LegendItemListener; + onLegendItemMinusClick?: LegendItemListener; } function updateChartStore(props: SettingSpecProps) { @@ -43,6 +46,9 @@ function updateChartStore(props: SettingSpecProps) { onBrushEnd, onLegendItemOver, onLegendItemOut, + onLegendItemClick, + onLegendItemMinusClick, + onLegendItemPlusClick, debug, } = props; if (!chartStore) { @@ -75,6 +81,15 @@ function updateChartStore(props: SettingSpecProps) { if (onLegendItemOut) { chartStore.setOnLegendItemOutListener(onLegendItemOut); } + if (onLegendItemClick) { + chartStore.setOnLegendItemClickListener(onLegendItemClick); + } + if (onLegendItemPlusClick) { + chartStore.setOnLegendItemPlusClickListener(onLegendItemPlusClick); + } + if (onLegendItemMinusClick) { + chartStore.setOnLegendItemMinusClickListener(onLegendItemMinusClick); + } } export class SettingsComponent extends PureComponent { diff --git a/src/specs/specs_parser.test.tsx b/src/specs/specs_parser.test.tsx index f8ae9c8149..74ab4feaf1 100644 --- a/src/specs/specs_parser.test.tsx +++ b/src/specs/specs_parser.test.tsx @@ -11,4 +11,21 @@ describe('Specs parser', () => { mount(component); expect(chartStore.specsInitialized.get()).toBe(true); }); + test('resets selectedDataSeries on component update', () => { + const chartStore = new ChartStore(); + const reset = jest.fn((): void => { return; }); + chartStore.resetSelectedDataSeries = reset; + + const component = mount(); + component.update(); + component.setState({ foo: 'bar' }); + expect(reset).toBeCalled(); + }); + test('updates initialization state on unmount', () => { + const chartStore = new ChartStore(); + chartStore.initialized.set(true); + const component = mount(); + component.unmount(); + expect(chartStore.initialized.get()).toBe(false); + }); }); diff --git a/src/specs/specs_parser.tsx b/src/specs/specs_parser.tsx index e33ab9fe36..f13560fa22 100644 --- a/src/specs/specs_parser.tsx +++ b/src/specs/specs_parser.tsx @@ -18,6 +18,7 @@ export class SpecsSpecRootComponent extends PureComponent { } componentDidUpdate() { this.props.chartStore!.specsInitialized.set(true); + this.props.chartStore!.resetSelectedDataSeries(); this.props.chartStore!.computeChart(); } componentWillUnmount() { diff --git a/src/state/chart_state.test.ts b/src/state/chart_state.test.ts index e02a167e01..c224f1d18f 100644 --- a/src/state/chart_state.test.ts +++ b/src/state/chart_state.test.ts @@ -66,6 +66,19 @@ describe('Chart Store', () => { expect(seriesDomainsAndData).not.toBeUndefined(); }); + test('can initialize selectedDataSeries depending on previous state', () => { + const selectedDataSeries = [{ specId: SPEC_ID, colorValues: [] }]; + + store.selectedDataSeries = null; + store.computeChart(); + expect(store.selectedDataSeries).toEqual(selectedDataSeries); + + store.selectedDataSeries = selectedDataSeries; + store.specsInitialized.set(true); + store.computeChart(); + expect(store.selectedDataSeries).toEqual(selectedDataSeries); + }); + test('can add an axis', () => { const axisSpec: AxisSpec = { id: AXIS_ID, @@ -216,6 +229,105 @@ describe('Chart Store', () => { expect(outListener.mock.calls.length).toBe(1); }); + test('can respond to legend item click event', () => { + const legendListener = jest.fn((ds: DataSeriesColorsValues | null): void => { return; }); + + store.legendItems = [firstLegendItem, secondLegendItem]; + store.selectedLegendItemIndex.set(null); + store.onLegendItemClickListener = undefined; + + store.onLegendItemClick(0); + expect(store.selectedLegendItemIndex.get()).toBe(0); + expect(legendListener).not.toBeCalled(); + + store.setOnLegendItemClickListener(legendListener); + store.onLegendItemClick(0); + expect(store.selectedLegendItemIndex.get()).toBe(null); + expect(legendListener).toBeCalledWith(null); + + store.setOnLegendItemClickListener(legendListener); + store.onLegendItemClick(1); + expect(store.selectedLegendItemIndex.get()).toBe(1); + expect(legendListener).toBeCalledWith(secondLegendItem.value); + }); + + test('can respond to a legend item plus click event', () => { + const legendListener = jest.fn((ds: DataSeriesColorsValues | null): void => { return; }); + + store.legendItems = [firstLegendItem, secondLegendItem]; + store.selectedLegendItemIndex.set(null); + store.onLegendItemPlusClickListener = undefined; + + store.onLegendItemPlusClick(); + expect(legendListener).not.toBeCalled(); + + store.setOnLegendItemPlusClickListener(legendListener); + store.onLegendItemPlusClick(); + expect(legendListener).toBeCalledWith(null); + + store.selectedLegendItemIndex.set(0); + store.onLegendItemPlusClick(); + expect(legendListener).toBeCalledWith(firstLegendItem.value); + }); + + test('can respond to a legend item minus click event', () => { + const legendListener = jest.fn((ds: DataSeriesColorsValues | null): void => { return; }); + + store.legendItems = [firstLegendItem, secondLegendItem]; + store.selectedLegendItemIndex.set(null); + store.onLegendItemMinusClickListener = undefined; + + store.onLegendItemMinusClick(); + expect(legendListener).not.toBeCalled(); + + store.setOnLegendItemMinusClickListener(legendListener); + store.onLegendItemMinusClick(); + expect(legendListener).toBeCalledWith(null); + + store.selectedLegendItemIndex.set(0); + store.onLegendItemMinusClick(); + expect(legendListener).toBeCalledWith(firstLegendItem.value); + }); + + test('can toggle series visibility', () => { + const computeChart = jest.fn((): void => { return; }); + + store.legendItems = [firstLegendItem, secondLegendItem]; + store.selectedDataSeries = null; + store.computeChart = computeChart; + + store.toggleSeriesVisibility(3); + expect(store.selectedDataSeries).toEqual(null); + expect(computeChart).not.toBeCalled(); + + store.selectedDataSeries = [firstLegendItem.value, secondLegendItem.value]; + store.toggleSeriesVisibility(0); + expect(store.selectedDataSeries).toEqual([secondLegendItem.value]); + expect(computeChart).toBeCalled(); + + store.selectedDataSeries = [firstLegendItem.value]; + store.toggleSeriesVisibility(0); + expect(store.selectedDataSeries).toEqual([]); + }); + + test('can toggle single series visibility', () => { + const computeChart = jest.fn((): void => { return; }); + + store.legendItems = [firstLegendItem, secondLegendItem]; + store.selectedDataSeries = null; + store.computeChart = computeChart; + + store.toggleSingleSeries(3); + expect(store.selectedDataSeries).toEqual(null); + expect(computeChart).not.toBeCalled(); + + store.toggleSingleSeries(0); + expect(store.selectedDataSeries).toEqual([firstLegendItem.value]); + + store.toggleSingleSeries(0); + expect(store.selectedDataSeries).toEqual([secondLegendItem.value]); + }); + test('can set an element click listener', () => { const clickListener = (value: GeometryValue): void => { return; }; store.setOnElementClickListener(clickListener); @@ -242,6 +354,12 @@ describe('Chart Store', () => { store.removeOnLegendItemOverListener(); expect(store.onLegendItemOverListener).toEqual(undefined); + + store.removeOnLegendItemPlusClickListener(); + expect(store.onLegendItemPlusClickListener).toEqual(undefined); + + store.removeOnLegendItemMinusClickListener(); + expect(store.onLegendItemMinusClickListener).toEqual(undefined); }); test('can respond to a brush end event', () => { @@ -344,4 +462,42 @@ describe('Chart Store', () => { localStore.computeChart(); expect(localStore.initialized.get()).toBe(false); }); + + test('only computes chart if series specs exist', () => { + const localStore = new ChartStore(); + + localStore.parentDimensions = { + width: 100, + height: 100, + top: 0, + left: 0, + }; + + localStore.seriesSpecs = new Map(); + localStore.computeChart(); + expect(localStore.initialized.get()).toBe(false); + }); + + test('can set the color for a series', () => { + const computeChart = jest.fn((): void => { return; }); + store.computeChart = computeChart; + store.legendItems = [firstLegendItem, secondLegendItem]; + + const expectedCustomColors = new Map(); + expectedCustomColors.set(firstLegendItem.label, 'foo'); + + store.setSeriesColor(-1, 'foo'); + expect(computeChart).not.toBeCalled(); + expect(store.customSeriesColors).toEqual(new Map()); + + store.setSeriesColor(0, 'foo'); + expect(computeChart).toBeCalled(); + expect(store.customSeriesColors).toEqual(expectedCustomColors); + }); + + test('can reset selectedDataSeries', () => { + store.selectedDataSeries = [firstLegendItem.value]; + store.resetSelectedDataSeries(); + expect(store.selectedDataSeries).toBe(null); + }); }); diff --git a/src/state/chart_state.ts b/src/state/chart_state.ts index dfead521a1..40cd6f0c91 100644 --- a/src/state/chart_state.ts +++ b/src/state/chart_state.ts @@ -46,8 +46,12 @@ import { computeChartTransform, computeSeriesDomains, computeSeriesGeometries, + findSelectedDataSeries, + getAllDataSeriesColorValues, getAxesSpecForSpecId, + getLegendItemByIndex, Transform, + updateSelectedDataSeries, } from './utils'; export interface TooltipPosition { top?: number; @@ -126,6 +130,9 @@ export class ChartStore { legendItems: LegendItem[] = []; highlightedLegendItemIndex: IObservableValue = observable.box(null); + selectedLegendItemIndex: IObservableValue = observable.box(null); + selectedDataSeries: DataSeriesColorsValues[] | null = null; + customSeriesColors: Map = new Map(); tooltipData = observable.box | null>(null); tooltipPosition = observable.box<{ x: number; y: number } | null>(); @@ -135,9 +142,12 @@ export class ChartStore { onElementOverListener?: ElementOverListener; onElementOutListener?: () => undefined; onBrushEndListener?: BrushEndListener; - onLegendItemOverListener?: LegendItemListener; onLegendItemOutListener?: () => undefined; + onLegendItemClickListener?: LegendItemListener; + onLegendItemPlusClickListener?: LegendItemListener; + onLegendItemMinusClickListener?: LegendItemListener; + onLegendItemVisibilityToggleClickListener?: LegendItemListener; geometries: { points: PointGeometry[]; @@ -197,6 +207,11 @@ export class ChartStore { return index == null ? null : this.legendItems[index]; }); + selectedLegendItem = computed(() => { + const index = this.selectedLegendItemIndex.get(); + return index == null ? null : this.legendItems[index]; + }); + onLegendItemOver = action((legendItemIndex: number) => { if (legendItemIndex >= this.legendItems.length || legendItemIndex < 0) { this.highlightedLegendItemIndex.set(null); @@ -218,6 +233,76 @@ export class ChartStore { } }); + onLegendItemClick = action((legendItemIndex: number) => { + if (legendItemIndex !== this.selectedLegendItemIndex.get()) { + this.selectedLegendItemIndex.set(legendItemIndex); + } else { + this.selectedLegendItemIndex.set(null); + } + + if (this.onLegendItemClickListener) { + const currentLegendItem = this.selectedLegendItem.get(); + const listenerData = currentLegendItem ? currentLegendItem.value : null; + this.onLegendItemClickListener(listenerData); + } + }); + + onLegendItemPlusClick = action(() => { + if (this.onLegendItemPlusClickListener) { + const currentLegendItem = this.selectedLegendItem.get(); + const listenerData = currentLegendItem ? currentLegendItem.value : null; + this.onLegendItemPlusClickListener(listenerData); + } + }); + + onLegendItemMinusClick = action(() => { + if (this.onLegendItemMinusClickListener) { + const currentLegendItem = this.selectedLegendItem.get(); + const listenerData = currentLegendItem ? currentLegendItem.value : null; + this.onLegendItemMinusClickListener(listenerData); + } + }); + + toggleSingleSeries = action((legendItemIndex: number) => { + const legendItem = getLegendItemByIndex(this.legendItems, legendItemIndex); + + if (legendItem) { + if (findSelectedDataSeries(this.selectedDataSeries, legendItem.value) > -1) { + this.selectedDataSeries = + this.legendItems + .filter((item: LegendItem, idx: number) => idx !== legendItemIndex) + .map((item: LegendItem) => item.value); + } else { + this.selectedDataSeries = [legendItem.value]; + } + + this.computeChart(); + } + }); + + toggleSeriesVisibility = action((legendItemIndex: number) => { + const legendItem = getLegendItemByIndex(this.legendItems, legendItemIndex); + + if (legendItem) { + this.selectedDataSeries = updateSelectedDataSeries(this.selectedDataSeries, legendItem.value); + this.computeChart(); + } + }); + + setSeriesColor = action((legendItemIndex: number, color: string) => { + const legendItem = getLegendItemByIndex(this.legendItems, legendItemIndex); + + if (legendItem) { + const key = legendItem.label; + this.customSeriesColors.set(key, color); + this.computeChart(); + } + }); + + resetSelectedDataSeries() { + this.selectedDataSeries = null; + } + setOnElementClickListener(listener: ElementClickListener) { this.onElementClickListener = listener; } @@ -236,6 +321,15 @@ export class ChartStore { setOnLegendItemOutListener(listener: () => undefined) { this.onLegendItemOutListener = listener; } + setOnLegendItemClickListener(listener: LegendItemListener) { + this.onLegendItemClickListener = listener; + } + setOnLegendItemPlusClickListener(listener: LegendItemListener) { + this.onLegendItemPlusClickListener = listener; + } + setOnLegendItemMinusClickListener(listener: LegendItemListener) { + this.onLegendItemMinusClickListener = listener; + } removeElementClickListener() { this.onElementClickListener = undefined; } @@ -251,6 +345,12 @@ export class ChartStore { removeOnLegendItemOutListener() { this.onLegendItemOutListener = undefined; } + removeOnLegendItemPlusClickListener() { + this.onLegendItemPlusClickListener = undefined; + } + removeOnLegendItemMinusClickListener() { + this.onLegendItemMinusClickListener = undefined; + } onBrushEnd(start: Point, end: Point) { if (!this.onBrushEndListener) { return; @@ -323,19 +423,39 @@ export class ChartStore { return; } - const seriesDomains = computeSeriesDomains(this.seriesSpecs); + // When specs are not initialized, reset selectedDataSeries to null + if (!this.specsInitialized.get()) { + this.selectedDataSeries = null; + } + + // The second argument is optional; if not supplied, then all series will be factored into computations + // Otherwise, selectedDataSeries is used to restrict the computation for just the selected series + const seriesDomains = computeSeriesDomains(this.seriesSpecs, this.selectedDataSeries); this.seriesDomainsAndData = seriesDomains; + + // If this.selectedDataSeries is null, initialize with all series + if (!this.selectedDataSeries) { + this.selectedDataSeries = getAllDataSeriesColorValues(seriesDomains.seriesColors); + } + // tslint:disable-next-line:no-console // console.log({colors: seriesDomains.seriesColors}); // tslint:disable-next-line:no-console // console.log({ seriesDomains }); - const seriesColorMap = getSeriesColorMap(seriesDomains.seriesColors, this.chartTheme.colors); + const seriesColorMap = getSeriesColorMap( + seriesDomains.seriesColors, + this.chartTheme.colors, + this.customSeriesColors, + this.seriesSpecs, + ); + this.legendItems = computeLegend( seriesDomains.seriesColors, seriesColorMap, this.seriesSpecs, this.chartTheme.colors.defaultVizColor, + this.selectedDataSeries, ); // tslint:disable-next-line:no-console // console.log({ legendItems: this.legendItems }); diff --git a/src/state/utils.test.ts b/src/state/utils.test.ts index 81b77b518c..d7259bb62d 100644 --- a/src/state/utils.test.ts +++ b/src/state/utils.test.ts @@ -1,8 +1,18 @@ +import { LegendItem } from '../lib/series/legend'; +import { DataSeriesColorsValues } from '../lib/series/series'; import { BasicSeriesSpec } from '../lib/series/specs'; + import { BARCHART_1Y0G, BARCHART_1Y1G } from '../lib/series/utils/test_dataset'; + import { getGroupId, getSpecId, SpecId } from '../lib/utils/ids'; import { ScaleType } from '../lib/utils/scales/scales'; -import { computeSeriesDomains } from './utils'; +import { + computeSeriesDomains, + findSelectedDataSeries, + getAllDataSeriesColorValues, + getLegendItemByIndex, + updateSelectedDataSeries, +} from './utils'; describe('Chart State utils', () => { it('should compute and format specifications for non stacked chart', () => { @@ -114,4 +124,94 @@ describe('Chart State utils', () => { expect(domains.formattedDataSeries.stacked).toMatchSnapshot(); expect(domains.formattedDataSeries.nonStacked).toMatchSnapshot(); }); + it('should get a legend item by index', () => { + const dataSeriesColorValues = { + specId: getSpecId('foo'), + colorValues: [], + }; + + const firstItem = { + color: 'foo', + label: 'foo', + value: dataSeriesColorValues, + }; + + const secondItem = { + color: 'bar', + label: 'bar', + value: dataSeriesColorValues, + }; + + const legendItems: LegendItem[] = [firstItem, secondItem]; + const legendItemIndex = 1; + + expect(getLegendItemByIndex([], legendItemIndex)).toBe(null); + expect(getLegendItemByIndex(legendItems, 2)).toEqual(null); + expect(getLegendItemByIndex(legendItems, legendItemIndex)).toEqual(secondItem); + }); + it('should check if a DataSeriesColorValues item exists in a list of DataSeriesColorValues', () => { + 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'], + }; + + const selectedSeries = [dataSeriesValuesA, dataSeriesValuesB]; + + expect(findSelectedDataSeries(selectedSeries, dataSeriesValuesA)).toBe(0); + expect(findSelectedDataSeries(selectedSeries, dataSeriesValuesC)).toBe(-1); + expect(findSelectedDataSeries(null, dataSeriesValuesA)).toBe(-1); + }); + it('should update a list of DataSeriesColorsValues given a selected DataSeriesColorValues item', () => { + 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'], + }; + + const selectedSeries = [dataSeriesValuesA, dataSeriesValuesB]; + const addedSelectedSeries = [dataSeriesValuesA, dataSeriesValuesB, dataSeriesValuesC]; + const removedSelectedSeries = [dataSeriesValuesB]; + + expect(updateSelectedDataSeries(selectedSeries, dataSeriesValuesC)).toEqual(addedSelectedSeries); + expect(updateSelectedDataSeries(selectedSeries, dataSeriesValuesA)).toEqual(removedSelectedSeries); + expect(updateSelectedDataSeries(null, dataSeriesValuesA)).toEqual([dataSeriesValuesA]); + }); + it('should return all of the DataSeriesColorValues on initialization', () => { + const dataSeriesValuesA: DataSeriesColorsValues = { + specId: getSpecId('a'), + colorValues: ['a', 'b', 'c'], + }; + + const dataSeriesValuesB: DataSeriesColorsValues = { + specId: getSpecId('b'), + colorValues: ['a', 'b', 'c'], + }; + + const colorMap = new Map(); + colorMap.set('a', dataSeriesValuesA); + colorMap.set('b', dataSeriesValuesB); + + const expected = [dataSeriesValuesA, dataSeriesValuesB]; + + expect(getAllDataSeriesColorValues(colorMap)).toEqual(expected); + }); }); diff --git a/src/state/utils.ts b/src/state/utils.ts index 776cfc22ef..997ae8863c 100644 --- a/src/state/utils.ts +++ b/src/state/utils.ts @@ -2,6 +2,7 @@ import { isVertical } from '../lib/axes/axis_utils'; import { CurveType } from '../lib/series/curves'; import { mergeXDomain, XDomain } from '../lib/series/domains/x_domain'; import { mergeYDomain, YDomain } from '../lib/series/domains/y_domain'; +import { LegendItem } from '../lib/series/legend'; import { AreaGeometry, BarGeometry, @@ -21,6 +22,7 @@ import { getSplittedSeries, RawDataSeries, } from '../lib/series/series'; +import { isEqualSeriesKey } from '../lib/series/series_utils'; import { AreaSeriesSpec, AxisSpec, @@ -45,8 +47,51 @@ export interface BrushExtent { maxY: number; } +export function getLegendItemByIndex(items: LegendItem[], index: number): LegendItem | null { + if (index < 0 || index >= items.length) { + return null; + } + return items[index]; +} + +export function findSelectedDataSeries( + series: DataSeriesColorsValues[] | null, + value: DataSeriesColorsValues, +): number { + if (!series) { + return -1; + } + + return series.findIndex((item: DataSeriesColorsValues) => { + return isEqualSeriesKey(item.colorValues, value.colorValues) && item.specId === value.specId; + }); +} + +export function getAllDataSeriesColorValues( + seriesColors: Map, +): DataSeriesColorsValues[] { + return Array.from(seriesColors.values()); +} + +export function updateSelectedDataSeries( + series: DataSeriesColorsValues[] | null, + value: DataSeriesColorsValues, +): DataSeriesColorsValues[] { + + const seriesIndex = findSelectedDataSeries(series, value); + const updatedSeries = series ? [...series] : []; + + if (seriesIndex > -1) { + updatedSeries.splice(seriesIndex, 1); + } else { + updatedSeries.push(value); + } + return updatedSeries; +} + export function computeSeriesDomains( seriesSpecs: Map, + selectedDataSeries?: DataSeriesColorsValues[] | null, ): { xDomain: XDomain; yDomain: YDomain[]; @@ -57,7 +102,7 @@ export function computeSeriesDomains( }; seriesColors: Map; } { - const { splittedSeries, xValues, seriesColors } = getSplittedSeries(seriesSpecs); + const { splittedSeries, xValues, seriesColors } = getSplittedSeries(seriesSpecs, selectedDataSeries); // tslint:disable-next-line:no-console // console.log({ splittedSeries, xValues, seriesColors }); const splittedDataSeries = [...splittedSeries.values()]; diff --git a/stories/interactions.tsx b/stories/interactions.tsx index a59aaab412..618565ac8a 100644 --- a/stories/interactions.tsx +++ b/stories/interactions.tsx @@ -29,6 +29,9 @@ const onElementListeners = { const onLegendItemListeners = { onLegendItemOver: action('onLegendItemOver'), onLegendItemOut: action('onLegendItemOut'), + onLegendItemClick: action('onLegendItemClick'), + onLegendItemPlusClick: action('onLegendItemPlusClick'), + onLegendItemMinusClick: action('onLegendItemMinusClick'), }; storiesOf('Interactions', module) @@ -119,7 +122,7 @@ storiesOf('Interactions', module) ); }) - .add('click/hovers on legend items [bar chart] (TO DO click)', () => { + .add('click/hovers on legend items [bar chart]', () => { return ( @@ -149,7 +152,7 @@ storiesOf('Interactions', module) ); }) - .add('click/hovers on legend items [area chart] (TO DO click)', () => { + .add('click/hovers on legend items [area chart]', () => { return ( @@ -189,7 +192,7 @@ storiesOf('Interactions', module) ); }) - .add('click/hovers on legend items [line chart] (TO DO click)', () => { + .add('click/hovers on legend items [line chart]', () => { return ( @@ -269,7 +272,7 @@ storiesOf('Interactions', module) ); }) - .add('click/hovers on legend items [mixed chart] (TO DO click)', () => { + .add('click/hovers on legend items [mixed chart]', () => { return ( diff --git a/stories/legend.tsx b/stories/legend.tsx index 9aa109613e..1ca2c4db01 100644 --- a/stories/legend.tsx +++ b/stories/legend.tsx @@ -11,6 +11,7 @@ import { Settings, } from '../src/'; import * as TestDatasets from '../src/lib/series/utils/test_dataset'; +import { boolean } from '@storybook/addon-knobs'; storiesOf('Legend', module) .add('right', () => { @@ -75,7 +76,7 @@ storiesOf('Legend', module) }) .add('left', () => { return ( - + ); + }) + .add('changing specs', () => { + const splitSeries = boolean('split series', true) ? + ['g1', 'g2'] : undefined; + return ( + + + + Number(d).toFixed(2)} + /> + + + + ); }); +