diff --git a/e2e/screenshots/all.test.ts-snapshots/baselines/legend/custom-legend-chrome-linux.png b/e2e/screenshots/all.test.ts-snapshots/baselines/legend/custom-legend-chrome-linux.png new file mode 100644 index 0000000000..23700bc900 Binary files /dev/null and b/e2e/screenshots/all.test.ts-snapshots/baselines/legend/custom-legend-chrome-linux.png differ diff --git a/packages/charts/api/charts.api.md b/packages/charts/api/charts.api.md index 3a4301ca0b..b480ad26a9 100644 --- a/packages/charts/api/charts.api.md +++ b/packages/charts/api/charts.api.md @@ -441,6 +441,9 @@ export interface BubbleSeriesStyle { // @public (undocumented) export type CategoryKey = string; +// @public (undocumented) +export type CategoryLabel = string; + // @public (undocumented) export interface Cell { // Warning: (ae-forgotten-export) The symbol "HeatmapCellDatum" needs to be exported by the entry point index.d.ts @@ -644,6 +647,29 @@ export type CustomAnnotationTooltip = ComponentType<{ datum: LineAnnotationDatum | RectAnnotationDatum; }> | null; +// @public +export type CustomLegend = ComponentType; + +// @public +export interface CustomLegendProps { + // (undocumented) + items: { + seriesIdentifiers: SeriesIdentifier[]; + path: LegendPath; + color: Color; + label: CategoryLabel; + seriesType?: SeriesType; + pointStyle?: PointStyle; + extraValue?: PrimitiveValue; + isSeriesHidden?: boolean; + onItemOverActon: () => void; + onItemOutAction: () => void; + onItemClickAction: (negate: boolean) => void; + }[]; + // (undocumented) + pointerValue?: PointerValue; +} + // @public export type CustomTooltip = ComponentType>; @@ -1582,6 +1608,7 @@ export type LegendPositionConfig = { // @public export interface LegendSpec { + customLegend?: CustomLegend; flatLegend?: boolean; legendAction?: LegendAction; // (undocumented) @@ -2097,6 +2124,13 @@ export const PointerUpdateTrigger: Readonly<{ // @public (undocumented) export type PointerUpdateTrigger = $Values; +// @public +export interface PointerValue { + formattedValue: string; + value: any; + valueAccessor?: Accessor; +} + // @public (undocumented) export const PointShape: Readonly<{ Circle: "circle"; @@ -2445,7 +2479,7 @@ export const Settings: (props: SFProps; +export const settingsBuildProps: BuildProps; // @public (undocumented) export type SettingsProps = ComponentProps; @@ -3006,18 +3040,15 @@ export const TooltipType: Readonly<{ export type TooltipType = $Values; // @public -export interface TooltipValue { +export interface TooltipValue extends PointerValue { color: Color; datum?: D; formattedMarkValue?: string | null; - formattedValue: string; isHighlighted: boolean; isVisible: boolean; label: string; markValue?: number | null; seriesIdentifier: SI; - value: any; - valueAccessor?: Accessor; } // @public diff --git a/packages/charts/src/common/category.ts b/packages/charts/src/common/category.ts index 9b909c3b13..aa53c7d8cb 100644 --- a/packages/charts/src/common/category.ts +++ b/packages/charts/src/common/category.ts @@ -18,5 +18,5 @@ /** @public */ export type CategoryKey = string; -/** @internal */ +/** @public */ export type CategoryLabel = string; diff --git a/packages/charts/src/components/legend/custom_legend.tsx b/packages/charts/src/components/legend/custom_legend.tsx new file mode 100644 index 0000000000..e93a1128e0 --- /dev/null +++ b/packages/charts/src/components/legend/custom_legend.tsx @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { connect } from 'react-redux'; + +import { CustomLegendProps, CustomLegend as CustomLegendComponent } from '../../specs'; +import { GlobalChartState } from '../../state/chart_state'; +import { getPointerValueSelector } from '../../state/selectors/get_pointer_value'; + +interface Props extends CustomLegendProps { + component: CustomLegendComponent; +} + +const CustomLegendComponent: React.FC = ({ component: Component, ...props }) => ; + +const mapStateToProps = (state: GlobalChartState) => ({ + pointerValue: getPointerValueSelector(state), +}); + +/** @internal */ +export const CustomLegend = connect(mapStateToProps)(CustomLegendComponent); diff --git a/packages/charts/src/components/legend/legend.tsx b/packages/charts/src/components/legend/legend.tsx index 204de752d1..8d0328f692 100644 --- a/packages/charts/src/components/legend/legend.tsx +++ b/packages/charts/src/components/legend/legend.tsx @@ -33,6 +33,7 @@ import { hasMostlyRTLItems, HorizontalAlignment, LayoutDirection, VerticalAlignm import { Dimensions, Size } from '../../utils/dimensions'; import { LIGHT_THEME } from '../../utils/themes/light_theme'; import { Theme } from '../../utils/themes/theme'; +import { CustomLegend } from './custom_legend'; import { LegendItemProps, renderLegendItem } from './legend_item'; import { getLegendPositionConfig, legendPositionStyle } from './position_style'; import { getLegendStyle, getLegendListStyle } from './style_utils'; @@ -112,11 +113,28 @@ function LegendComponent(props: LegendStateProps & LegendDispatchProps) { const positionStyle = legendPositionStyle(config, size, chartDimensions, containerDimensions); return (
-
-
    - {items.map((item, index) => renderLegendItem(item, itemProps, index))} -
-
+ {config.customLegend ? ( +
+ ({ + ...customProps, + seriesIdentifiers, + path, + extraValue: itemProps.extraValues.get(seriesIdentifiers[0].key)?.get(childId || ''), + onItemOutAction: itemProps.mouseOutAction, + onItemOverActon: () => itemProps.mouseOverAction(path), + onItemClickAction: (negate: boolean) => itemProps.toggleDeselectSeriesAction(seriesIdentifiers, negate), + }))} + /> +
+ ) : ( +
+
    + {items.map((item, index) => renderLegendItem(item, itemProps, index))} +
+
+ )}
); } diff --git a/packages/charts/src/index.ts b/packages/charts/src/index.ts index bb94735a4f..b931372104 100644 --- a/packages/charts/src/index.ts +++ b/packages/charts/src/index.ts @@ -27,6 +27,7 @@ export { DebugStateAxis, DebugStateBase, DebugStateLegendItem, + PointerValue, } from './state/types'; export { toEntries } from './utils/common'; export { CurveType } from './utils/curves'; @@ -43,7 +44,7 @@ export { } from './chart_types/xy_chart/annotations/types'; export { GeometryValue, BandedAccessorType } from './utils/geometry'; export { LegendPath, LegendPathElement } from './state/actions/legend'; -export { CategoryKey } from './common/category'; +export { CategoryKey, CategoryLabel } from './common/category'; export { Layer as PartitionLayer, PartitionProps } from './chart_types/partition_chart/specs/index'; export { FillLabelConfig as PartitionFillLabel, PartitionStyle } from './utils/themes/partition'; export { PartitionLayout } from './chart_types/partition_chart/layout/types/config_types'; diff --git a/packages/charts/src/specs/settings.tsx b/packages/charts/src/specs/settings.tsx index 12394cf6f7..b5b51fe5fb 100644 --- a/packages/charts/src/specs/settings.tsx +++ b/packages/charts/src/specs/settings.tsx @@ -12,15 +12,17 @@ import { CustomXDomain, GroupByAccessor, Spec } from '.'; import { Cell } from '../chart_types/heatmap/layout/types/viewmodel_types'; import { PrimitiveValue } from '../chart_types/partition_chart/layout/utils/group_by_rollup'; import { LegendStrategy } from '../chart_types/partition_chart/layout/utils/highlighted_geoms'; -import { LineAnnotationDatum, RectAnnotationDatum } from '../chart_types/specs'; +import { LineAnnotationDatum, RectAnnotationDatum, SeriesType } from '../chart_types/specs'; import { WordModel } from '../chart_types/wordcloud/layout/types/viewmodel_types'; import { XYChartSeriesIdentifier } from '../chart_types/xy_chart/utils/series'; +import { CategoryLabel } from '../common/category'; import { Color } from '../common/colors'; import { SeriesIdentifier } from '../common/series_id'; import { TooltipPortalSettings } from '../components'; import { ScaleContinuousType, ScaleOrdinalType } from '../scales'; import { LegendPath } from '../state/actions/legend'; import { SFProps, useSpecFactory } from '../state/spec_factory'; +import { PointerValue } from '../state/types'; import { HorizontalAlignment, LayoutDirection, @@ -34,7 +36,7 @@ import { Dimensions } from '../utils/dimensions'; import { GeometryValue } from '../utils/geometry'; import { GroupId, SpecId } from '../utils/ids'; import { SeriesCompareFn } from '../utils/series_sort'; -import { PartialTheme, Theme } from '../utils/themes/theme'; +import { PartialTheme, PointStyle, Theme } from '../utils/themes/theme'; import { BinAgg, BrushAxis, Direction, PointerEventType, PointerUpdateTrigger, settingsBuildProps } from './constants'; import { TooltipSettings } from './tooltip'; @@ -365,6 +367,33 @@ export type LegendPositionConfig = { // TODO add grow factor: fill, shrink, fixed column size }; +/** + * The props for {@link CustomLegend} + * @public + */ +export interface CustomLegendProps { + pointerValue?: PointerValue; + items: { + seriesIdentifiers: SeriesIdentifier[]; + path: LegendPath; + color: Color; + label: CategoryLabel; + seriesType?: SeriesType; + pointStyle?: PointStyle; + extraValue?: PrimitiveValue; + isSeriesHidden?: boolean; + onItemOverActon: () => void; + onItemOutAction: () => void; + onItemClickAction: (negate: boolean) => void; + }[]; +} + +/** + * The react component used to render a custom legend + * @public + */ +export type CustomLegend = ComponentType; + /** * The legend configuration * @public @@ -418,6 +447,10 @@ export interface LegendSpec { * A SeriesSortFn to sort the legend values (top-bottom) */ legendSort?: SeriesCompareFn; + /** + * Override the legend with a custom component. + */ + customLegend?: CustomLegend; } /** diff --git a/packages/charts/src/specs/tooltip.ts b/packages/charts/src/specs/tooltip.ts index 350e550425..0b5832edb6 100644 --- a/packages/charts/src/specs/tooltip.ts +++ b/packages/charts/src/specs/tooltip.ts @@ -15,7 +15,7 @@ import { SeriesIdentifier } from '../common/series_id'; import { TooltipPortalSettings } from '../components/portal'; import { CustomTooltip } from '../components/tooltip'; import { buildSFProps, SFProps, useSpecFactory } from '../state/spec_factory'; -import { Accessor } from '../utils/accessor'; +import { PointerValue } from '../state/types'; import { Datum, stripUndefined } from '../utils/common'; import { SpecType, TooltipStickTo, TooltipType } from './constants'; import { Spec } from './index'; @@ -25,19 +25,12 @@ import { SettingsSpec } from './settings'; * This interface describe the properties of single value shown in the tooltip * @public */ -export interface TooltipValue { +export interface TooltipValue + extends PointerValue { /** * The label of the tooltip value */ label: string; - /** - * The value - */ - value: any; - /** - * The formatted value to display - */ - formattedValue: string; /** * The mark value */ @@ -62,10 +55,6 @@ export interface TooltipValue; /** * The datum associated with the current tooltip value * Maybe not available diff --git a/packages/charts/src/state/selectors/get_legend_config_selector.ts b/packages/charts/src/state/selectors/get_legend_config_selector.ts index 3cd60542c6..cc75f9155e 100644 --- a/packages/charts/src/state/selectors/get_legend_config_selector.ts +++ b/packages/charts/src/state/selectors/get_legend_config_selector.ts @@ -22,6 +22,7 @@ export const getLegendConfigSelector = createCustomCachedSelector( legendPosition, legendStrategy, onLegendItemClick, + customLegend, showLegend, onLegendItemMinusClick, onLegendItemOut, @@ -38,6 +39,7 @@ export const getLegendConfigSelector = createCustomCachedSelector( legendPosition: getLegendPositionConfig(legendPosition), legendStrategy, onLegendItemClick, + customLegend, showLegend, onLegendItemMinusClick, onLegendItemOut, diff --git a/packages/charts/src/state/selectors/get_pointer_value.ts b/packages/charts/src/state/selectors/get_pointer_value.ts new file mode 100644 index 0000000000..b20ff8a616 --- /dev/null +++ b/packages/charts/src/state/selectors/get_pointer_value.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { GlobalChartState } from '../chart_state'; +import { PointerValue } from '../types'; + +/** @internal */ +export const getPointerValueSelector = (state: GlobalChartState): PointerValue | undefined => { + // TODO: this is taken from the tooltip header currently. Should in the future + // be implemented separately (and probably used *as* the tooltip header). + const header = state.internalChartState?.getTooltipInfo(state)?.header; + if (header) { + const { value, formattedValue, valueAccessor } = header; + return { value, formattedValue, valueAccessor }; + } +}; diff --git a/packages/charts/src/state/types.ts b/packages/charts/src/state/types.ts index f674c649a5..d815f9fdb1 100644 --- a/packages/charts/src/state/types.ts +++ b/packages/charts/src/state/types.ts @@ -8,8 +8,9 @@ import type { Cell } from '../chart_types/heatmap/layout/types/viewmodel_types'; import { Pixels } from '../common/geometry'; -import { AnnotationType, LineAnnotationDatum, RectAnnotationDatum } from '../specs'; -import type { Position } from '../utils/common'; +import { AnnotationType, BaseDatum, LineAnnotationDatum, RectAnnotationDatum } from '../specs'; +import { Accessor } from '../utils/accessor'; +import type { Datum, Position } from '../utils/common'; import type { GeometryValue } from '../utils/geometry'; import { LineAnnotationStyle, RectAnnotationStyle } from '../utils/themes/theme'; @@ -132,3 +133,23 @@ export interface DebugState { heatmap?: HeatmapDebugState; partition?: PartitionDebugState[]; } + +/** + * Contains the value of the non-dependent variable at the point where the mouse + * pointer is. + * @public + */ +export interface PointerValue { + /** + * The value + */ + value: any; + /** + * The formatted value to display + */ + formattedValue: string; + /** + * The accessor linked to the current tooltip value + */ + valueAccessor?: Accessor; +} diff --git a/storybook/stories/legend/16_custom_legend.story.tsx b/storybook/stories/legend/16_custom_legend.story.tsx new file mode 100644 index 0000000000..09bb220a5e --- /dev/null +++ b/storybook/stories/legend/16_custom_legend.story.tsx @@ -0,0 +1,89 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import moment from 'moment'; +import React from 'react'; + +import { + Axis, + AreaSeries, + Chart, + Position, + ScaleType, + Settings, + timeFormatter, + CustomLegend, + Tooltip, +} from '@elastic/charts'; +import { KIBANA_METRICS } from '@elastic/charts/src/utils/data_samples/test_dataset_kibana'; + +import { useBaseTheme } from '../../use_base_theme'; + +const dateFormatter = timeFormatter('HH:mm'); +const data1 = KIBANA_METRICS.metrics.kibana_os_load[0].data.map((d) => [ + ...d, + KIBANA_METRICS.metrics.kibana_os_load[0].metric.label, +]); +const data2 = KIBANA_METRICS.metrics.kibana_os_load[1].data.map((d) => [ + ...d, + KIBANA_METRICS.metrics.kibana_os_load[1].metric.label, +]); +const data3 = KIBANA_METRICS.metrics.kibana_os_load[2].data.map((d) => [ + ...d, + KIBANA_METRICS.metrics.kibana_os_load[2].metric.label, +]); +const allMetrics = [...data3, ...data2, ...data1]; + +export const Example = () => { + const customLegend: CustomLegend = ({ items, pointerValue }) => ( +
+

{pointerValue ? moment(pointerValue?.value).format('HH:mm') : 'System Load'}

+ {items.map((i) => ( + + ))} +
+ ); + + return ( + + + null} /> + + Number(d).toFixed(2)} ticks={5} /> + + + ); +}; + +Example.parameters = { + markdown: `When using a custom legend, please always specify a fixed \`legendSize\` in the \`Settings\` prop to avoid a wrongly computed default legend size.`, +}; diff --git a/storybook/stories/legend/legend.stories.tsx b/storybook/stories/legend/legend.stories.tsx index d72f80e706..89e42e5877 100644 --- a/storybook/stories/legend/legend.stories.tsx +++ b/storybook/stories/legend/legend.stories.tsx @@ -26,3 +26,4 @@ export { Example as actions } from './11_legend_actions.story'; export { Example as margins } from './12_legend_margins.story'; export { Example as singleSeries } from './14_single_series.story'; export { Example as sortItems } from './15_legend_sort.story'; +export { Example as customLegend } from './16_custom_legend.story';