diff --git a/e2e/screenshots/all.test.ts-snapshots/baselines/legend/tabular-data-chrome-linux.png b/e2e/screenshots/all.test.ts-snapshots/baselines/legend/tabular-data-chrome-linux.png new file mode 100644 index 0000000000..e2ae1432ac Binary files /dev/null and b/e2e/screenshots/all.test.ts-snapshots/baselines/legend/tabular-data-chrome-linux.png differ diff --git a/e2e/screenshots/legend_stories.test.ts-snapshots/legend-stories/legend-tabular-data/should-correctly-display-bottom-positon-chrome-linux.png b/e2e/screenshots/legend_stories.test.ts-snapshots/legend-stories/legend-tabular-data/should-correctly-display-bottom-positon-chrome-linux.png new file mode 100644 index 0000000000..4255d3ac64 Binary files /dev/null and b/e2e/screenshots/legend_stories.test.ts-snapshots/legend-stories/legend-tabular-data/should-correctly-display-bottom-positon-chrome-linux.png differ diff --git a/e2e/screenshots/legend_stories.test.ts-snapshots/legend-stories/legend-tabular-data/should-correctly-display-current-and-last-value-median-values-chrome-linux.png b/e2e/screenshots/legend_stories.test.ts-snapshots/legend-stories/legend-tabular-data/should-correctly-display-current-and-last-value-median-values-chrome-linux.png new file mode 100644 index 0000000000..6170b238c2 Binary files /dev/null and b/e2e/screenshots/legend_stories.test.ts-snapshots/legend-stories/legend-tabular-data/should-correctly-display-current-and-last-value-median-values-chrome-linux.png differ diff --git a/e2e/screenshots/legend_stories.test.ts-snapshots/legend-stories/legend-tabular-data/should-correctly-display-default-dataset-dataset-chrome-linux.png b/e2e/screenshots/legend_stories.test.ts-snapshots/legend-stories/legend-tabular-data/should-correctly-display-default-dataset-dataset-chrome-linux.png new file mode 100644 index 0000000000..e2ae1432ac Binary files /dev/null and b/e2e/screenshots/legend_stories.test.ts-snapshots/legend-stories/legend-tabular-data/should-correctly-display-default-dataset-dataset-chrome-linux.png differ diff --git a/e2e/screenshots/legend_stories.test.ts-snapshots/legend-stories/legend-tabular-data/should-correctly-display-left-positon-chrome-linux.png b/e2e/screenshots/legend_stories.test.ts-snapshots/legend-stories/legend-tabular-data/should-correctly-display-left-positon-chrome-linux.png new file mode 100644 index 0000000000..275834f4f7 Binary files /dev/null and b/e2e/screenshots/legend_stories.test.ts-snapshots/legend-stories/legend-tabular-data/should-correctly-display-left-positon-chrome-linux.png differ diff --git a/e2e/screenshots/legend_stories.test.ts-snapshots/legend-stories/legend-tabular-data/should-correctly-display-long-copy-dataset-dataset-chrome-linux.png b/e2e/screenshots/legend_stories.test.ts-snapshots/legend-stories/legend-tabular-data/should-correctly-display-long-copy-dataset-dataset-chrome-linux.png new file mode 100644 index 0000000000..701faf3536 Binary files /dev/null and b/e2e/screenshots/legend_stories.test.ts-snapshots/legend-stories/legend-tabular-data/should-correctly-display-long-copy-dataset-dataset-chrome-linux.png differ diff --git a/e2e/screenshots/legend_stories.test.ts-snapshots/legend-stories/legend-tabular-data/should-correctly-display-median-max-min-average-first-non-null-value-std-deviation-first-value-current-and-last-value-values-chrome-linux.png b/e2e/screenshots/legend_stories.test.ts-snapshots/legend-stories/legend-tabular-data/should-correctly-display-median-max-min-average-first-non-null-value-std-deviation-first-value-current-and-last-value-values-chrome-linux.png new file mode 100644 index 0000000000..48bb25e4fd Binary files /dev/null and b/e2e/screenshots/legend_stories.test.ts-snapshots/legend-stories/legend-tabular-data/should-correctly-display-median-max-min-average-first-non-null-value-std-deviation-first-value-current-and-last-value-values-chrome-linux.png differ diff --git a/e2e/screenshots/legend_stories.test.ts-snapshots/legend-stories/legend-tabular-data/should-correctly-display-median-max-min-average-first-non-null-value-values-chrome-linux.png b/e2e/screenshots/legend_stories.test.ts-snapshots/legend-stories/legend-tabular-data/should-correctly-display-median-max-min-average-first-non-null-value-values-chrome-linux.png new file mode 100644 index 0000000000..305f1540f2 Binary files /dev/null and b/e2e/screenshots/legend_stories.test.ts-snapshots/legend-stories/legend-tabular-data/should-correctly-display-median-max-min-average-first-non-null-value-values-chrome-linux.png differ diff --git a/e2e/screenshots/legend_stories.test.ts-snapshots/legend-stories/legend-tabular-data/should-correctly-display-median-values-chrome-linux.png b/e2e/screenshots/legend_stories.test.ts-snapshots/legend-stories/legend-tabular-data/should-correctly-display-median-values-chrome-linux.png new file mode 100644 index 0000000000..c48ba8c6b8 Binary files /dev/null and b/e2e/screenshots/legend_stories.test.ts-snapshots/legend-stories/legend-tabular-data/should-correctly-display-median-values-chrome-linux.png differ diff --git a/e2e/screenshots/legend_stories.test.ts-snapshots/legend-stories/legend-tabular-data/should-correctly-display-right-positon-chrome-linux.png b/e2e/screenshots/legend_stories.test.ts-snapshots/legend-stories/legend-tabular-data/should-correctly-display-right-positon-chrome-linux.png new file mode 100644 index 0000000000..e2ae1432ac Binary files /dev/null and b/e2e/screenshots/legend_stories.test.ts-snapshots/legend-stories/legend-tabular-data/should-correctly-display-right-positon-chrome-linux.png differ diff --git a/e2e/screenshots/legend_stories.test.ts-snapshots/legend-stories/legend-tabular-data/should-correctly-display-short-copy-dataset-dataset-chrome-linux.png b/e2e/screenshots/legend_stories.test.ts-snapshots/legend-stories/legend-tabular-data/should-correctly-display-short-copy-dataset-dataset-chrome-linux.png new file mode 100644 index 0000000000..7dded67163 Binary files /dev/null and b/e2e/screenshots/legend_stories.test.ts-snapshots/legend-stories/legend-tabular-data/should-correctly-display-short-copy-dataset-dataset-chrome-linux.png differ diff --git a/e2e/screenshots/legend_stories.test.ts-snapshots/legend-stories/legend-tabular-data/should-correctly-display-top-positon-chrome-linux.png b/e2e/screenshots/legend_stories.test.ts-snapshots/legend-stories/legend-tabular-data/should-correctly-display-top-positon-chrome-linux.png new file mode 100644 index 0000000000..521b64b74a Binary files /dev/null and b/e2e/screenshots/legend_stories.test.ts-snapshots/legend-stories/legend-tabular-data/should-correctly-display-top-positon-chrome-linux.png differ diff --git a/e2e/tests/legend_stories.test.ts b/e2e/tests/legend_stories.test.ts index c41970e1e6..0914cd320d 100644 --- a/e2e/tests/legend_stories.test.ts +++ b/e2e/tests/legend_stories.test.ts @@ -334,4 +334,52 @@ test.describe('Legend stories', () => { }, ); }); + test.describe('Legend tabular data', () => { + const datasetKnob = (p1: string, p2: string) => `&globals=&knob-Dataset_Legend=${p1}&knob-dataset=${p2}`; + const getDatasetUrl = (p1: string, p2: string, others = '') => { + return `http://localhost:9001/?path=/story/legend--tabular-data${datasetKnob(p1, p2)}${others}`; + }; + + const legendPositionKnob = (position: string) => `&knob-Legend position_Legend=${position}`; + + const getPositionUrl = (p1: string, others = '') => { + return `http://localhost:9001/?path=/story/legend--tabular-data${legendPositionKnob(p1)}${others}`; + }; + + const legendValueKnob = (values: string[]) => values.map((v, i) => `&knob-Legend Value_Legend[${i}]=${v}`).join(''); + const getlegendValueUrl = (values: string[], others = '') => { + return `http://localhost:9001/?path=/story/legend--tabular-data${legendValueKnob(values)}${others}`; + }; + + pwEach.test<[string, string]>([ + ['shortCopyDataset', 'short copy'], + ['longCopyDataset', 'long copy'], + ['defaultDataset', 'default'], + ])( + ([p2]) => `should correctly display ${p2} dataset`, + async (page, [p1, p2]) => { + await common.expectChartAtUrlToMatchScreenshot(page)(getDatasetUrl(p1, p2)); + }, + ); + + pwEach.test(['right', 'left', 'top', 'bottom'])( + (p) => `should correctly display ${p} positon`, + async (page, p) => { + await common.expectChartAtUrlToMatchScreenshot(page)(getPositionUrl(p)); + }, + ); + + pwEach.test([ + ['median'], + ['currentAndLastValue', 'median'], + ['median', 'max', 'min', 'average', 'firstNonNullValue'], + + ['median', 'max', 'min', 'average', 'firstNonNullValue', 'stdDeviation', 'firstValue', 'currentAndLastValue'], + ])( + (p) => `should correctly display ${p} values`, + async (page, p) => { + await common.expectChartAtUrlToMatchScreenshot(page)(getlegendValueUrl(p)); + }, + ); + }); }); diff --git a/packages/charts/api/charts.api.md b/packages/charts/api/charts.api.md index fd0dab43ec..8cc5bcf163 100644 --- a/packages/charts/api/charts.api.md +++ b/packages/charts/api/charts.api.md @@ -1817,6 +1817,7 @@ export type LegendItemListener = (series: SeriesIdentifier[]) => void; export type LegendItemValue = { value: PrimitiveValue; label: string; + type: LegendValue; }; // @public (undocumented) @@ -1854,6 +1855,7 @@ export interface LegendSpec { legendSize: number; legendSort?: SeriesCompareFn; legendStrategy?: LegendStrategy; + legendTitle?: string; legendValues: Array; // (undocumented) onLegendItemClick?: LegendItemListener; @@ -1892,7 +1894,6 @@ export interface LegendStyle { // @public (undocumented) export const LegendValue: Readonly<{ - None: "none"; CurrentAndLastValue: "currentAndLastValue"; LastValue: "lastValue"; LastNonNullValue: "lastNonNullValue"; @@ -2751,7 +2752,7 @@ export const Settings: (props: SFProps; +export const settingsBuildProps: BuildProps; // @public (undocumented) export type SettingsProps = ComponentProps; diff --git a/packages/charts/src/chart_types/partition_chart/__snapshots__/partition.test.tsx.snap b/packages/charts/src/chart_types/partition_chart/__snapshots__/partition.test.tsx.snap index ceb5f5d1f1..c429c9808f 100644 --- a/packages/charts/src/chart_types/partition_chart/__snapshots__/partition.test.tsx.snap +++ b/packages/charts/src/chart_types/partition_chart/__snapshots__/partition.test.tsx.snap @@ -31,6 +31,7 @@ exports[`Retain hierarchy even with arbitrary names getLegendItems all distinct "values": [ { "label": "2", + "type": "value", "value": 2, }, ], @@ -68,6 +69,7 @@ exports[`Retain hierarchy even with arbitrary names getLegendItems all distinct "values": [ { "label": "1", + "type": "value", "value": 1, }, ], @@ -105,6 +107,7 @@ exports[`Retain hierarchy even with arbitrary names getLegendItems all distinct "values": [ { "label": "1", + "type": "value", "value": 1, }, ], @@ -138,6 +141,7 @@ exports[`Retain hierarchy even with arbitrary names getLegendItems all distinct "values": [ { "label": "2", + "type": "value", "value": 2, }, ], @@ -175,6 +179,7 @@ exports[`Retain hierarchy even with arbitrary names getLegendItems all distinct "values": [ { "label": "1", + "type": "value", "value": 1, }, ], @@ -212,6 +217,7 @@ exports[`Retain hierarchy even with arbitrary names getLegendItems all distinct "values": [ { "label": "1", + "type": "value", "value": 1, }, ], @@ -245,6 +251,7 @@ exports[`Retain hierarchy even with arbitrary names getLegendItems all distinct "values": [ { "label": "2", + "type": "value", "value": 2, }, ], @@ -282,6 +289,7 @@ exports[`Retain hierarchy even with arbitrary names getLegendItems all distinct "values": [ { "label": "1", + "type": "value", "value": 1, }, ], @@ -319,6 +327,7 @@ exports[`Retain hierarchy even with arbitrary names getLegendItems all distinct "values": [ { "label": "1", + "type": "value", "value": 1, }, ], @@ -357,6 +366,7 @@ exports[`Retain hierarchy even with arbitrary names getLegendItems special case: "values": [ { "label": "1", + "type": "value", "value": 1, }, ], @@ -394,6 +404,7 @@ exports[`Retain hierarchy even with arbitrary names getLegendItems special case: "values": [ { "label": "1", + "type": "value", "value": 1, }, ], @@ -432,6 +443,7 @@ exports[`Retain hierarchy even with arbitrary names getLegendItems special case: "values": [ { "label": "1", + "type": "value", "value": 1, }, ], @@ -469,6 +481,7 @@ exports[`Retain hierarchy even with arbitrary names getLegendItems special case: "values": [ { "label": "1", + "type": "value", "value": 1, }, ], diff --git a/packages/charts/src/chart_types/partition_chart/layout/viewmodel/hierarchy_of_arrays.ts b/packages/charts/src/chart_types/partition_chart/layout/viewmodel/hierarchy_of_arrays.ts index 86c7a20649..14e4d3f39c 100644 --- a/packages/charts/src/chart_types/partition_chart/layout/viewmodel/hierarchy_of_arrays.ts +++ b/packages/charts/src/chart_types/partition_chart/layout/viewmodel/hierarchy_of_arrays.ts @@ -7,7 +7,7 @@ */ import { isMosaic, isSunburst, isTreemap, isWaffle } from './viewmodel'; -import { LegendItemExtraValues } from '../../../../common/legend'; +import { LegendItemExtraValues, LegendValue } from '../../../../common/legend'; import { SeriesKey } from '../../../../common/series_id'; import { Relation } from '../../../../common/text_utils'; import { LegendPath } from '../../../../state/actions/legend'; @@ -118,7 +118,7 @@ export function getExtraValueMap( const { value, path, [CHILDREN_KEY]: children } = arrayNode; const values: LegendItemExtraValues = new Map(); const label = valueFormatter ? valueFormatter(value) : `${value}`; - values.set(key, { label, value }); + values.set(key, { label, value, type: LegendValue.Value }); keys.set(path.map(({ index }) => index).join('__'), values); if (depth < maxDepth) getExtraValueMap(layers, valueFormatter, children, maxDepth, depth + 1, keys); } diff --git a/packages/charts/src/chart_types/partition_chart/state/selectors/compute_legend.ts b/packages/charts/src/chart_types/partition_chart/state/selectors/compute_legend.ts index 06dcd513d8..a0458849cf 100644 --- a/packages/charts/src/chart_types/partition_chart/state/selectors/compute_legend.ts +++ b/packages/charts/src/chart_types/partition_chart/state/selectors/compute_legend.ts @@ -10,7 +10,7 @@ import { getPartitionSpecs } from './get_partition_specs'; import { getTrees } from './tree'; import { RGBATupleToString } from '../../../../common/color_library_wrappers'; import { Colors } from '../../../../common/colors'; -import { LegendItem } from '../../../../common/legend'; +import { LegendItem, LegendValue } from '../../../../common/legend'; import { SeriesIdentifier } from '../../../../common/series_id'; import { createCustomCachedSelector } from '../../../../state/create_selector'; import { getLegendConfigSelector } from '../../../../state/selectors/get_legend_config_selector'; @@ -127,6 +127,7 @@ function walkTree( { value: node[AGGREGATE_KEY], label: valueFormatter(node[AGGREGATE_KEY]), + type: LegendValue.Value, }, ], }, diff --git a/packages/charts/src/chart_types/xy_chart/legend/legend.ts b/packages/charts/src/chart_types/xy_chart/legend/legend.ts index 98bc2144e5..f57cfaebb7 100644 --- a/packages/charts/src/chart_types/xy_chart/legend/legend.ts +++ b/packages/charts/src/chart_types/xy_chart/legend/legend.ts @@ -7,7 +7,7 @@ */ import { Color } from '../../../common/colors'; -import { LegendItem, LegendValue } from '../../../common/legend'; +import { LegendItem } from '../../../common/legend'; import { SeriesKey, SeriesIdentifier } from '../../../common/series_id'; import { SettingsSpec } from '../../../specs'; import { isDefined, mergePartial } from '../../../utils/common'; @@ -16,7 +16,7 @@ import { getLegendCompareFn, SeriesCompareFn } from '../../../utils/series_sort' import { PointStyle, Theme } from '../../../utils/themes/theme'; import { XDomain } from '../domains/types'; import { isDatumFilled } from '../rendering/utils'; -import { getLegendValue } from '../state/utils/get_last_value'; +import { getLegendValues } from '../state/utils/get_legend_values'; import { getAxesSpecForSpecId, getSpecsById } from '../state/utils/spec'; import { Y0_ACCESSOR_POSTFIX, Y1_ACCESSOR_POSTFIX } from '../tooltip/tooltip'; import { defaultTickFormatter } from '../utils/axis_utils'; @@ -109,7 +109,7 @@ export function computeLegend( const legendItems: LegendItem[] = []; const defaultColor = theme.colors.defaultVizColor; - const legendValueMode = settingsSpec.legendValues[0] ?? LegendValue.None; + const legendValues = settingsSpec.legendValues ?? []; dataSeries.forEach((series) => { const { specId, yAccessor } = series; @@ -140,8 +140,7 @@ export function computeLegend( const pointStyle = getPointStyle(spec, theme); - const itemValue = getLegendValue(series, xDomain, legendValueMode, y1Accessor(series.stackMode)); - const formattedItemValue = itemValue !== null ? formatter(itemValue) : ''; + const legendValuesItems = getLegendValues(series, xDomain, legendValues, y1Accessor(series.stackMode), formatter); legendItems.push({ depth: 0, @@ -152,22 +151,19 @@ export function computeLegend( isSeriesHidden, isItemHidden: hideInLegend, isToggleable: true, - values: - itemValue !== null - ? [ - { - value: itemValue, - label: formattedItemValue, - }, - ] - : [], + values: legendValuesItems, path: [{ index: 0, value: seriesIdentifier.key }], keys: [specId, spec.groupId, yAccessor, ...series.splitAccessors.values()], pointStyle, }); if (banded) { - const bandedItemValue = getLegendValue(series, xDomain, legendValueMode, y0Accessor(series.stackMode)); - const bandedFormattedItemValue = bandedItemValue !== null ? formatter(bandedItemValue) : ''; + const bandedLegendValuesItems = getLegendValues( + series, + xDomain, + legendValues, + y0Accessor(series.stackMode), + formatter, + ); const labelY0 = getBandedLegendItemLabel(name, BandedAccessorType.Y0, postFixes); legendItems.push({ @@ -179,15 +175,7 @@ export function computeLegend( isSeriesHidden, isItemHidden: hideInLegend, isToggleable: true, - values: - bandedItemValue !== null - ? [ - { - value: bandedItemValue, - label: bandedFormattedItemValue, - }, - ] - : [], + values: bandedLegendValuesItems, path: [{ index: 0, value: seriesIdentifier.key }], keys: [specId, spec.groupId, yAccessor, ...series.splitAccessors.values()], pointStyle, @@ -201,7 +189,6 @@ export function computeLegend( return defaultXYLegendSeriesSort(aDs, bDs); }); const sortFn: SeriesCompareFn = settingsSpec.legendSort ?? legendSortFn; - return groupBy( legendItems.sort((a, b) => a.seriesIdentifiers[0] && b.seriesIdentifiers[0] ? sortFn(a.seriesIdentifiers[0], b.seriesIdentifiers[0]) : 0, diff --git a/packages/charts/src/chart_types/xy_chart/state/utils/common.test.ts b/packages/charts/src/chart_types/xy_chart/state/utils/common.test.ts index fe4ff09579..32da79c1f9 100644 --- a/packages/charts/src/chart_types/xy_chart/state/utils/common.test.ts +++ b/packages/charts/src/chart_types/xy_chart/state/utils/common.test.ts @@ -134,7 +134,7 @@ describe('Type Checks', () => { specId: 'bars', }, ], - values: [{ value: 6, label: '6.00' }], + values: [{ value: 6, label: '6.00', type: 'currentAndLastValue' }], isSeriesHidden: true, path: [], keys: [], @@ -149,7 +149,7 @@ describe('Type Checks', () => { specId: 'bars', }, ], - values: [{ value: 2, label: '2.00' }], + values: [{ value: 2, label: '2.00', type: 'currentAndLastValue' }], isSeriesHidden: true, path: [], keys: [], @@ -169,7 +169,7 @@ describe('Type Checks', () => { specId: 'bars', }, ], - values: [{ value: 6, label: '6.00' }], + values: [{ value: 6, label: '6.00', type: 'currentAndLastValue' }], isSeriesHidden: false, path: [], keys: [], @@ -184,7 +184,7 @@ describe('Type Checks', () => { specId: 'bars', }, ], - values: [{ value: 2, label: '2.00' }], + values: [{ value: 2, label: '2.00', type: 'currentAndLastValue' }], isSeriesHidden: true, path: [], keys: [], diff --git a/packages/charts/src/chart_types/xy_chart/state/utils/get_last_value.ts b/packages/charts/src/chart_types/xy_chart/state/utils/get_legend_values.ts similarity index 81% rename from packages/charts/src/chart_types/xy_chart/state/utils/get_last_value.ts rename to packages/charts/src/chart_types/xy_chart/state/utils/get_legend_values.ts index 5a283ad987..9ebcd207f1 100644 --- a/packages/charts/src/chart_types/xy_chart/state/utils/get_last_value.ts +++ b/packages/charts/src/chart_types/xy_chart/state/utils/get_legend_values.ts @@ -26,6 +26,29 @@ import { LegendValue } from '../../../../common/legend'; import { ScaleType } from '../../../../scales/constants'; import { XDomain } from '../../domains/types'; import { DataSeries, DataSeriesDatum } from '../../utils/series'; +import { TickFormatter } from '../../utils/specs'; + +/** + * This method return legend values from a DataSeries that correspond to the type of value requested. + * It in general compute the last, min, max, avg, sum of the value in a series. + * @internal + */ +export function getLegendValues( + series: DataSeries, + xDomain: XDomain, + types: LegendValue[], + valueAccessor: (d: DataSeriesDatum) => number | null, + formatter: TickFormatter | ((tick: unknown) => string), +) { + return types.map((type) => { + const value = getLegendValue(series, xDomain, type, valueAccessor); + return { + type, + label: typeof value === 'number' ? formatter(value) : '', + value, + }; + }); +} /** * This method return a value from a DataSeries that correspond to the type of value requested. @@ -80,7 +103,6 @@ export function getLegendValue( case LegendValue.DifferencePercent: return differencePercent(series.data, valueAccessor); default: - case LegendValue.None: return null; } } diff --git a/packages/charts/src/chart_types/xy_chart/tooltip/tooltip.ts b/packages/charts/src/chart_types/xy_chart/tooltip/tooltip.ts index 87537213c7..334e09f4db 100644 --- a/packages/charts/src/chart_types/xy_chart/tooltip/tooltip.ts +++ b/packages/charts/src/chart_types/xy_chart/tooltip/tooltip.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { LegendItemExtraValues } from '../../../common/legend'; +import { LegendItemExtraValues, LegendValue } from '../../../common/legend'; import { SeriesKey } from '../../../common/series_id'; import { TooltipValue } from '../../../specs'; import { PointerValue } from '../../../state/types'; @@ -29,7 +29,7 @@ export function getLegendItemExtraValues(tooltipValues: TooltipValue[]): Map { const current: LegendItemExtraValues = seriesTooltipValues.get(seriesIdentifier.key) ?? new Map(); if (valueAccessor === BandedAccessorType.Y0 || valueAccessor === BandedAccessorType.Y1) { - current.set(valueAccessor, { label: formattedValue, value }); + current.set(valueAccessor, { label: formattedValue, value, type: LegendValue.CurrentAndLastValue }); } seriesTooltipValues.set(seriesIdentifier.key, current); }); diff --git a/packages/charts/src/common/legend.ts b/packages/charts/src/common/legend.ts index dcc9ab0360..308713d7e6 100644 --- a/packages/charts/src/common/legend.ts +++ b/packages/charts/src/common/legend.ts @@ -20,11 +20,10 @@ import { PointStyle } from '../utils/themes/theme'; export type LegendItemChildId = CategoryKey; /** @public */ -export type LegendItemValue = { value: PrimitiveValue; label: string }; +export type LegendItemValue = { value: PrimitiveValue; label: string; type: LegendValue }; /** @public */ export const LegendValue = Object.freeze({ - None: 'none' as const, /** Value of the bucket being hovered or last bucket value when not hovering. */ CurrentAndLastValue: 'currentAndLastValue' as const, /** Last value considering all data points in the chart */ @@ -81,7 +80,7 @@ export type LegendItem = { label: CategoryLabel; isSeriesHidden?: boolean; isItemHidden?: boolean; - values: Array; + values: LegendItemValue[]; // TODO: Remove when partition layers are toggleable isToggleable?: boolean; keys: Array; @@ -91,3 +90,32 @@ export type LegendItem = { /** @internal */ export type LegendItemExtraValues = Map; + +/** @internal */ +export const shouldDisplayTable = (legendValues: LegendValue[]) => + legendValues.some((v) => v !== LegendValue.CurrentAndLastValue && v !== LegendValue.Value); +/** + * todo: i18n + * @internal + */ +export const legendValueTitlesMap = { + [LegendValue.CurrentAndLastValue]: 'Value', + [LegendValue.Value]: 'Value', + [LegendValue.Percent]: 'Percent', + [LegendValue.LastValue]: 'Last', + [LegendValue.LastNonNullValue]: 'Last non-null', + [LegendValue.FirstValue]: 'First', + [LegendValue.FirstNonNullValue]: 'First non-null', + [LegendValue.Average]: 'Avg', + [LegendValue.Median]: 'Mid', + [LegendValue.Min]: 'Min', + [LegendValue.Max]: 'Max', + [LegendValue.Total]: 'Total', + [LegendValue.Count]: 'Count', + [LegendValue.DistinctCount]: 'Dist Count', + [LegendValue.Variance]: 'Variance', + [LegendValue.StdDeviation]: 'Std dev', + [LegendValue.Range]: 'Range', + [LegendValue.Difference]: 'Diff', + [LegendValue.DifferencePercent]: 'Diff %', +}; diff --git a/packages/charts/src/components/__snapshots__/chart.test.tsx.snap b/packages/charts/src/components/__snapshots__/chart.test.tsx.snap index 635170f314..f4f071aa88 100644 --- a/packages/charts/src/components/__snapshots__/chart.test.tsx.snap +++ b/packages/charts/src/components/__snapshots__/chart.test.tsx.snap @@ -26,10 +26,10 @@ exports[`Chart should render the legend name test 1`] = `
    - -
  • + +
  • -
    +
    @@ -42,13 +42,13 @@ exports[`Chart should render the legend name test 1`] = `
    -
  • -
    +
diff --git a/packages/charts/src/components/_index.scss b/packages/charts/src/components/_index.scss index 587bf75fa1..9227a6eb92 100644 --- a/packages/charts/src/components/_index.scss +++ b/packages/charts/src/components/_index.scss @@ -2,6 +2,7 @@ @import 'container'; @import 'brush/index'; @import 'tooltip/components/index'; +@import 'legend/legend_table/index'; @import 'portal/index'; @import 'icons/index'; @import 'legend/index'; diff --git a/packages/charts/src/components/legend/__snapshots__/legend.test.tsx.snap b/packages/charts/src/components/legend/__snapshots__/legend.test.tsx.snap index e865ba3aa8..b69fc93ed9 100644 --- a/packages/charts/src/components/legend/__snapshots__/legend.test.tsx.snap +++ b/packages/charts/src/components/legend/__snapshots__/legend.test.tsx.snap @@ -1,10 +1,10 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Legend #legendColorPicker should match snapshot after onChange is called 1`] = ` -" -
  • +" +
  • -
    +
  • -
    -
  • + +
  • -
    +
  • -
    -
  • + +
  • -
    +
  • -
    -
  • + +
  • -
    +
  • -
    " +" `; exports[`Legend #legendColorPicker should match snapshot after onClose is called 1`] = ` -" -
  • +" +
  • -
    +
  • -
    -
  • + +
  • -
    +
  • -
    -
  • + +
  • -
    +
  • -
    -
  • + +
  • -
    +
  • -
    " +" `; exports[`Legend #legendColorPicker should render colorPicker when color is clicked 1`] = ` @@ -192,17 +192,17 @@ exports[`Legend #legendColorPicker should render colorPicker when color is click - " `; exports[`Legend #legendColorPicker should render colorPicker when color is clicked 2`] = ` -" -
  • +" +
  • -
    +
  • - +
    Custom Color Picker @@ -229,15 +229,15 @@ exports[`Legend #legendColorPicker should render colorPicker when color is click -
    -
    -
  • + +
  • -
    +
  • -
    -
  • + +
  • -
    +
  • -
    -
  • + +
  • -
    +
  • -
    " +" `; diff --git a/packages/charts/src/components/legend/_legend_item.scss b/packages/charts/src/components/legend/_legend_item.scss index f85d1f3d0e..97ee4aae81 100644 --- a/packages/charts/src/components/legend/_legend_item.scss +++ b/packages/charts/src/components/legend/_legend_item.scss @@ -12,7 +12,7 @@ $legendItemHeight: #{$euiFontSizeXS * $euiLineHeight}; position: relative; // wrapper is needed to isolate color icon when wrapped or not - .colorWrapper > *:first-of-type { + .echLegend__colorWrapper > *:first-of-type { // euiPopover adds a div with height of 19px otherwise // this prevents color dot from shifting when wrapped height: $legendItemHeight; @@ -21,7 +21,7 @@ $legendItemHeight: #{$euiFontSizeXS * $euiLineHeight}; &:not([dir='rtl']) > *:not(.background) { margin-left: $euiSizeXS; - &:last-child:not(.echLegendItem__extra) { + &:last-child:not(.echLegendItem__legendValue) { margin-right: $euiSizeXS; } } @@ -29,7 +29,7 @@ $legendItemHeight: #{$euiFontSizeXS * $euiLineHeight}; &[dir='rtl'] > *:not(.background) { margin-right: $euiSizeXS; - &:last-child:not(.echLegendItem__extra) { + &:last-child:not(.echLegendItem__legendValue) { margin-left: $euiSizeXS; } } @@ -103,7 +103,7 @@ $legendItemHeight: #{$euiFontSizeXS * $euiLineHeight}; } } - &__extra { + &__legendValue { @include euiFontSizeXS; text-align: right; flex: 0 0 auto; diff --git a/packages/charts/src/components/legend/_variables.scss b/packages/charts/src/components/legend/_variables.scss index c8543808be..2fb0e563c7 100644 --- a/packages/charts/src/components/legend/_variables.scss +++ b/packages/charts/src/components/legend/_variables.scss @@ -1,3 +1,15 @@ -$echLegendMaxWidth: 200px; -$echLegendRowGap: 8px; $echLegendColumnGap: 24px; +$echLegendRowHeight: 16px; + +$tableRowHoverColor: $euiColorLightestShade; +$legendBorderColor: $euiColorLightestShade; + +$echLegendRowGap: 8px; +$legendItemVerticalPadding: $echLegendRowGap / 2; +$echLegendTableCellPadding: 8px 4px; + +$echLegendTablePadding: 8px; +$echLegendHorizontalTablePadding: 4px 8px 4px 16px; +$legendBorderWidth: 1px; +$tableBorder: solid $legendBorderWidth $legendBorderColor; +$tableOutsideBorder: solid $legendBorderWidth $euiColorLightShade; diff --git a/packages/charts/src/components/legend/label.tsx b/packages/charts/src/components/legend/label.tsx index 0b8ac8ecc6..9016586955 100644 --- a/packages/charts/src/components/legend/label.tsx +++ b/packages/charts/src/components/legend/label.tsx @@ -57,19 +57,14 @@ ${modifierKey} + click: ${showSeriesMessage}`; */ export function Label({ label, - isToggleable, onToggle, + isToggleable, isSeriesHidden, options, hiddenSeriesCount, totalSeriesCount, }: LabelProps) { - const maxLines = Math.abs(options.maxLines); - const labelClassNames = classNames('echLegendItem__label', { - 'echLegendItem__label--clickable': Boolean(onToggle), - 'echLegendItem__label--singleline': maxLines === 1, - 'echLegendItem__label--multiline': maxLines > 1, - }); + const { className, dir, clampStyles } = getSharedProps(label, options, !!onToggle); const onClick: MouseEventHandler = useCallback( ({ metaKey, ctrlKey }) => onToggle?.(isAppleDevice ? metaKey : ctrlKey), @@ -82,9 +77,7 @@ export function Label({ [onToggle], ); - const dir = isRTLString(label) ? 'rtl' : 'ltr'; // forced for individual labels in case mixed charset const title = options.maxLines > 0 ? label : ''; // full text already visible - const clampStyles = maxLines > 1 ? { WebkitLineClamp: maxLines } : {}; const interactionsGuidanceText = getInteractivityTitle(!isSeriesHidden, hiddenSeriesCount, totalSeriesCount); @@ -95,7 +88,7 @@ export function Label({ role="button" tabIndex={0} dir={dir} - className={labelClassNames} + className={className} title={`${title}${interactionsGuidanceText}`} onClick={onClick} onKeyDown={onKeyDown} @@ -106,8 +99,32 @@ export function Label({ {label} ) : ( -
    +
    {label}
    ); } + +/** @internal */ +export function NonInteractiveLabel({ label, options }: { label: string; options: LegendLabelOptions }) { + const { className, dir, clampStyles } = getSharedProps(label, options); + return ( +
    + {label} +
    + ); +} + +function getSharedProps(label: string, options: LegendLabelOptions, isToggleable?: boolean) { + const maxLines = Math.abs(options.maxLines); + const className = classNames('echLegendItem__label', { + 'echLegendItem__label--clickable': Boolean(isToggleable), + 'echLegendItem__label--singleline': maxLines === 1, + 'echLegendItem__label--multiline': maxLines > 1, + }); + + const dir = isRTLString(label) ? 'rtl' : 'ltr'; // forced for individual labels in case mixed charset + const clampStyles = maxLines > 1 ? { WebkitLineClamp: maxLines } : {}; + + return { className, dir, clampStyles }; +} diff --git a/packages/charts/src/components/legend/legend.test.tsx b/packages/charts/src/components/legend/legend.test.tsx index b60e4a8e91..6e7afd8ca5 100644 --- a/packages/charts/src/components/legend/legend.test.tsx +++ b/packages/charts/src/components/legend/legend.test.tsx @@ -11,6 +11,8 @@ import React, { Component } from 'react'; import { Legend } from './legend'; import { LegendListItem } from './legend_item'; +import { LegendTable } from './legend_table'; +import { LegendTableRow } from './legend_table/legend_table_row'; import { LegendValue } from '../../common/legend'; import { SeededDataGenerator } from '../../mocks/utils'; import { ScaleType } from '../../scales/constants'; @@ -23,7 +25,7 @@ describe('Legend', () => { it('shall render the all the series names', () => { const wrapper = mount( - + { @@ -123,7 +125,7 @@ describe('Legend', () => { const data = dg.generateGroupedSeries(10, numberOfSeries, 'split'); const wrapper = mount( - + { const data = [{ x: 2, y: 5 }]; const wrapper = mount( - + { }); }); }); + describe('legend table', () => { + it('should render legend table when there is a legend value that is not CurrentAndLastValue', () => { + const wrapper = mount( + + + + , + ); + const legendTable = wrapper.find(LegendTable); + expect(legendTable.exists).toBeTruthy(); + const legendRows = legendTable.find(LegendTableRow); + expect(legendRows.exists).toBeTruthy(); + expect(legendRows).toHaveLength(5); + const expected = ['Min', 'group0123', 'group1123', 'group2123', 'group3123']; + legendRows.forEach((row, i) => { + expect(row.text()).toBe(expected[i]); + }); + }); + }); }); diff --git a/packages/charts/src/components/legend/legend.tsx b/packages/charts/src/components/legend/legend.tsx index ebdd32b9ce..f047ae548d 100644 --- a/packages/charts/src/components/legend/legend.tsx +++ b/packages/charts/src/components/legend/legend.tsx @@ -7,21 +7,24 @@ */ import classNames from 'classnames'; -import React from 'react'; +import React, { useCallback } from 'react'; import { connect } from 'react-redux'; import { Dispatch, bindActionCreators } from 'redux'; import { CustomLegend } from './custom_legend'; import { LegendItemProps, LegendListItem } from './legend_item'; +import { LegendTable } from './legend_table'; import { getLegendPositionConfig, legendPositionStyle } from './position_style'; import { getLegendStyle, getLegendListStyle } from './style_utils'; -import { LegendItem, LegendItemExtraValues } from '../../common/legend'; +import { LegendItem, LegendItemExtraValues, shouldDisplayTable } from '../../common/legend'; +import { SeriesIdentifier } from '../../common/series_id'; import { DEFAULT_LEGEND_CONFIG, LegendSpec } from '../../specs'; import { clearTemporaryColors, setTemporaryColor, setPersistedColor } from '../../state/actions/colors'; import { onToggleDeselectSeriesAction, onLegendItemOutAction, onLegendItemOverAction, + LegendPath, } from '../../state/actions/legend'; import { GlobalChartState } from '../../state/chart_state'; import { getChartThemeSelector } from '../../state/selectors/get_chart_theme'; @@ -45,7 +48,7 @@ interface LegendStateProps { chartDimensions: Dimensions; containerDimensions: Dimensions; chartTheme: Theme; - size: Size; + size: Size & { seriesWidth?: number }; config: LegendSpec; items: LegendItem[]; extraValues: Map; @@ -72,6 +75,28 @@ function LegendComponent(props: LegendStateProps & LegendDispatchProps) { config, } = props; + const { onLegendItemOut, onLegendItemOver } = config; + const { onItemOutAction, onItemOverAction } = props; + + const onLegendItemMouseOver = useCallback( + (seriesIdentifiers: SeriesIdentifier[], path: LegendPath) => { + // call the settings listener directly if available + if (onLegendItemOver) { + onLegendItemOver(seriesIdentifiers); + } + onItemOverAction(path); + }, + [onItemOverAction, onLegendItemOver], + ); + + const onLegendItemMouseOut = useCallback(() => { + // call the settings listener directly if available + if (onLegendItemOut) { + onLegendItemOut(); + } + onItemOutAction(); + }, [onLegendItemOut, onItemOutAction]); + if (items.every(({ isItemHidden }) => isItemHidden)) { return null; } @@ -99,21 +124,23 @@ function LegendComponent(props: LegendStateProps & LegendDispatchProps) { hiddenItems: items.filter(({ isSeriesHidden }) => isSeriesHidden).length, extraValues: props.extraValues, legendValues: config.legendValues, - onMouseOut: config.onLegendItemOut, - onMouseOver: config.onLegendItemOver, + onLegendItemMouseOver, + onLegendItemMouseOut, onClick: config.onLegendItemClick, clearTemporaryColorsAction: props.clearTemporaryColors, setPersistedColorAction: props.setPersistedColor, setTemporaryColorAction: props.setTemporaryColor, - mouseOutAction: props.onItemOutAction, - mouseOverAction: props.onItemOverAction, toggleDeselectSeriesAction: props.onToggleDeselectSeriesAction, colorPicker: config.legendColorPicker, action: config.legendAction, labelOptions: legend.labelOptions, flatLegend: config.flatLegend ?? DEFAULT_LEGEND_CONFIG.flatLegend, + legendTitle: config.legendTitle, }; + const positionStyle = legendPositionStyle(config, size, chartDimensions, containerDimensions); + const isTableView = shouldDisplayTable(itemProps.legendValues); + return (
    {config.customLegend ? ( @@ -125,12 +152,16 @@ function LegendComponent(props: LegendStateProps & LegendDispatchProps) { seriesIdentifiers, path, extraValue: itemProps.extraValues.get(seriesIdentifiers[0]?.key ?? '')?.get(childId ?? ''), - onItemOutAction: itemProps.mouseOutAction, - onItemOverActon: () => itemProps.mouseOverAction(path), + onItemOutAction, + onItemOverActon: () => onItemOverAction(path), onItemClickAction: (negate: boolean) => itemProps.toggleDeselectSeriesAction(seriesIdentifiers, negate), }))} />
    + ) : isTableView ? ( +
    + +
    ) : (
      diff --git a/packages/charts/src/components/legend/legend_color_picker.tsx b/packages/charts/src/components/legend/legend_color_picker.tsx new file mode 100644 index 0000000000..f3d711b3a8 --- /dev/null +++ b/packages/charts/src/components/legend/legend_color_picker.tsx @@ -0,0 +1,80 @@ +/* + * 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 { Color as ItemColor } from './color'; +import { LegendItemProps } from './legend_item'; +import { Color } from '../../common/colors'; + +/** @internal */ +export const useLegendColorPicker = ({ + item: { color, isSeriesHidden, label, pointStyle, seriesIdentifiers }, + colorPicker: ColorPickerRenderer, + clearTemporaryColorsAction, + setTemporaryColorAction, + setPersistedColorAction, +}: LegendItemProps) => { + const [isOpen, setIsOpen] = React.useState(false); + const colorRef = React.useRef(null); + + const shouldClearPersistedColor = React.useRef(false); + const toggleIsOpen = () => { + setIsOpen((prevIsOpen) => !prevIsOpen); + }; + const handleColorClick = (changeable: boolean) => + changeable + ? (event: React.MouseEvent) => { + event.stopPropagation(); + toggleIsOpen(); + } + : undefined; + + const handleColorPickerClose = () => { + const seriesKeys = seriesIdentifiers.map(({ key }) => key); + setPersistedColorAction(seriesKeys, shouldClearPersistedColor.current ? null : color); + clearTemporaryColorsAction(); + requestAnimationFrame(() => colorRef.current?.focus()); + toggleIsOpen(); + }; + + const handleColorPickerChange = (c: Color | null) => { + const seriesKeys = seriesIdentifiers.map(({ key }) => key); + shouldClearPersistedColor.current = c === null; + setTemporaryColorAction(seriesKeys, c); + }; + + const hasColorPicker = Boolean(ColorPickerRenderer); + + const renderItemColor = () => ( + + ); + + const renderColorPickerPopup = () => + ColorPickerRenderer && + isOpen && + colorRef.current && ( + + ); + + return { renderItemColor, renderColorPickerPopup }; +}; diff --git a/packages/charts/src/components/legend/legend_item.tsx b/packages/charts/src/components/legend/legend_item.tsx index 7047eed27c..12f1d9ed96 100644 --- a/packages/charts/src/components/legend/legend_item.tsx +++ b/packages/charts/src/components/legend/legend_item.tsx @@ -7,248 +7,129 @@ */ import classNames from 'classnames'; -import React, { Component, createRef, MouseEventHandler, CSSProperties } from 'react'; +import React, { CSSProperties, useCallback } from 'react'; -import { Color as ItemColor } from './color'; import { Label as ItemLabel } from './label'; +import { useLegendColorPicker } from './legend_color_picker'; +import { SharedLegendItemProps } from './types'; import { getExtra } from './utils'; -import { Color } from '../../common/colors'; import { LegendItem, LegendItemExtraValues, LegendValue } from '../../common/legend'; import { SeriesIdentifier } from '../../common/series_id'; -import { - LegendItemListener, - BasicListener, - LegendColorPicker, - LegendAction, - LegendPositionConfig, -} from '../../specs/settings'; -import { - clearTemporaryColors as clearTemporaryColorsAction, - setTemporaryColor as setTemporaryColorAction, - setPersistedColor as setPersistedColorAction, -} from '../../state/actions/colors'; -import { - onLegendItemOutAction, - onLegendItemOverAction, - onToggleDeselectSeriesAction, -} from '../../state/actions/legend'; -import { LayoutDirection } from '../../utils/common'; -import { deepEqual } from '../../utils/fast_deep_equal'; -import { LegendLabelOptions } from '../../utils/themes/theme'; +import { LayoutDirection, isDefined } from '../../utils/common'; /** @internal */ export const LEGEND_HIERARCHY_MARGIN = 10; /** @internal */ -export interface LegendItemProps { +export interface LegendItemProps extends SharedLegendItemProps { item: LegendItem; - flatLegend: boolean; - totalItems: number; - hiddenItems: number; - positionConfig: LegendPositionConfig; - extraValues: Map; - legendValues: Array; - isMostlyRTL: boolean; - labelOptions: LegendLabelOptions; - colorPicker?: LegendColorPicker; - action?: LegendAction; - onClick?: LegendItemListener; - onMouseOut?: BasicListener; - onMouseOver?: LegendItemListener; - mouseOutAction: typeof onLegendItemOutAction; - mouseOverAction: typeof onLegendItemOverAction; - clearTemporaryColorsAction: typeof clearTemporaryColorsAction; - setTemporaryColorAction: typeof setTemporaryColorAction; - setPersistedColorAction: typeof setPersistedColorAction; - toggleDeselectSeriesAction: typeof onToggleDeselectSeriesAction; -} - -interface LegendItemState { - isOpen: boolean; - actionActive: boolean; } /** @internal */ -export class LegendListItem extends Component { - static displayName = 'LegendItem'; - - shouldClearPersistedColor = false; - - colorRef = createRef(); - - state: LegendItemState = { - isOpen: false, - actionActive: false, - }; - - shouldComponentUpdate(nextProps: LegendItemProps, nextState: LegendItemState) { - return !deepEqual(this.props, nextProps) || !deepEqual(this.state, nextState); - } - - handleColorClick = (changeable: boolean): MouseEventHandler | undefined => - changeable - ? (event) => { - event.stopPropagation(); - this.toggleIsOpen(); - } - : undefined; - - toggleIsOpen = () => { - this.setState(({ isOpen }) => ({ isOpen: !isOpen })); - }; - - onLegendItemMouseOver = () => { - const { onMouseOver, mouseOverAction, item } = this.props; - // call the settings listener directly if available - if (onMouseOver) { - onMouseOver(item.seriesIdentifiers); - } - mouseOverAction(item.path); - }; - - onLegendItemMouseOut = () => { - const { onMouseOut, mouseOutAction } = this.props; - // call the settings listener directly if available - if (onMouseOut) { - onMouseOut(); +export const prepareLegendValues = ( + item: LegendItem, + legendValues: LegendValue[], + totalItems: number, + extraValues: Map, +) => { + return legendValues.map((legendValue) => { + if (legendValue === LegendValue.Value || legendValue === LegendValue.CurrentAndLastValue) { + return getExtra(extraValues, item, totalItems); } - mouseOutAction(); - }; + return item.values.find(({ type }) => type === legendValue); + }); +}; - /** - * Returns click function only if toggleable or click listern is provided - */ - onLabelToggle = (legendItemId: SeriesIdentifier[]): ((negate: boolean) => void) | undefined => { - const { item, onClick, toggleDeselectSeriesAction, totalItems } = this.props; - if (totalItems <= 1 || (!item.isToggleable && !onClick)) { - return; - } +/** @internal */ +export const LegendListItem: React.FC = (props) => { + const { + extraValues, + item, + legendValues, + totalItems, + action: Action, + positionConfig, + labelOptions, + isMostlyRTL, + flatLegend, + onClick, + toggleDeselectSeriesAction, + hiddenItems, + onLegendItemMouseOver, + onLegendItemMouseOut, + } = props; + const { color, isSeriesHidden, isItemHidden, seriesIdentifiers, label, depth, path, isToggleable } = item; + + const itemClassNames = classNames('echLegendItem', { + 'echLegendItem--hidden': isSeriesHidden, + 'echLegendItem--vertical': positionConfig.direction === LayoutDirection.Vertical, + }); + + const legendValueItems = prepareLegendValues(item, legendValues, totalItems, extraValues).filter(isDefined); + + const style: CSSProperties = flatLegend + ? {} + : { + [isMostlyRTL ? 'marginRight' : 'marginLeft']: LEGEND_HIERARCHY_MARGIN * (depth ?? 0), + }; + + const onLabelToggle = useCallback( + (legendItemId: SeriesIdentifier[]) => (negate: boolean) => { + if (totalItems <= 1 || (!isToggleable && !onClick)) { + return; + } - return (negate) => { if (onClick) { onClick(legendItemId); } - if (item.isToggleable) { + if (isToggleable) { toggleDeselectSeriesAction(legendItemId, negate); } - }; - }; - - renderColorPicker() { - const { - colorPicker: ColorPicker, - item, - clearTemporaryColorsAction, - setTemporaryColorAction, - setPersistedColorAction, - } = this.props; - const { seriesIdentifiers, color } = item; - const seriesKeys = seriesIdentifiers.map(({ key }) => key); - const handleClose = () => { - setPersistedColorAction(seriesKeys, this.shouldClearPersistedColor ? null : color); - clearTemporaryColorsAction(); - requestAnimationFrame(() => this.colorRef?.current?.focus()); - this.toggleIsOpen(); - }; - const handleChange = (c: Color | null) => { - this.shouldClearPersistedColor = c === null; - setTemporaryColorAction(seriesKeys, c); - }; - if (ColorPicker && this.state.isOpen && this.colorRef.current) { - return ( - +
    • onLegendItemMouseOver(seriesIdentifiers, path)} + onMouseLeave={onLegendItemMouseOut} + style={style} + dir={isMostlyRTL ? 'rtl' : 'ltr'} + data-ech-series-name={label} + > +
      +
      {renderItemColor()}
      + 1 && item.isToggleable} + onToggle={onLabelToggle(seriesIdentifiers)} + isSeriesHidden={isSeriesHidden} + totalSeriesCount={totalItems} + hiddenSeriesCount={hiddenItems} /> - ); - } - } - - render() { - const { - extraValues, - item, - legendValues, - colorPicker, - totalItems, - hiddenItems, - action: Action, - positionConfig, - labelOptions, - isMostlyRTL, - flatLegend, - } = this.props; - const { color, isSeriesHidden, isItemHidden, seriesIdentifiers, label, pointStyle } = item; - - if (isItemHidden) return null; - - const itemClassNames = classNames('echLegendItem', { - 'echLegendItem--hidden': isSeriesHidden, - 'echLegendItem--vertical': positionConfig.direction === LayoutDirection.Vertical, - }); - const hasColorPicker = Boolean(colorPicker); - - // only the first for now until https://github.com/elastic/elastic-charts/issues/2096 - const legendValue = - legendValues[0] === LegendValue.CurrentAndLastValue - ? getExtra(extraValues, item, totalItems) - : legendValues.length > 0 - ? item.values[0] - : undefined; - - const style: CSSProperties = flatLegend - ? {} - : { - [isMostlyRTL ? 'marginRight' : 'marginLeft']: LEGEND_HIERARCHY_MARGIN * (item.depth ?? 0), - }; - return ( - <> -
    • -
      -
      - + {!isSeriesHidden + ? legendValueItems.map((legendValueItem) => + legendValueItem.label !== '' ? ( +
      + {legendValueItem.label} +
      + ) : null, + ) + : null} + {Action && ( +
      +
      - 1 && item.isToggleable} - onToggle={this.onLabelToggle(seriesIdentifiers)} - isSeriesHidden={isSeriesHidden} - totalSeriesCount={totalItems} - hiddenSeriesCount={hiddenItems} - /> - {legendValue && !isSeriesHidden && ( -
      - {legendValue.label} -
      - )} - {Action && ( -
      - -
      - )} -
    • - {this.renderColorPicker()} - - ); - } -} + )} + + {renderColorPickerPopup()} + + ); +}; diff --git a/packages/charts/src/components/legend/legend_table/_index.scss b/packages/charts/src/components/legend/legend_table/_index.scss new file mode 100644 index 0000000000..4e37691961 --- /dev/null +++ b/packages/charts/src/components/legend/legend_table/_index.scss @@ -0,0 +1,4 @@ +@import 'legend_table'; +@import 'legend_single_item'; +@import 'legend_table_cell'; +@import 'legend_table_header'; diff --git a/packages/charts/src/components/legend/legend_table/_legend_single_item.scss b/packages/charts/src/components/legend/legend_table/_legend_single_item.scss new file mode 100644 index 0000000000..a59ea39abf --- /dev/null +++ b/packages/charts/src/components/legend/legend_table/_legend_single_item.scss @@ -0,0 +1,76 @@ +@import '../variables'; +@import '../../mixins'; + +.echLegendTable__item { + color: $euiTextColor; + + .echLegendItem__action { + padding-top: $euiSizeXS / 2; + max-width: 16px; + height: $echLegendRowHeight; + &:empty { + width: 0; + } + } + + &:last-child .echLegendTable__cell { + border-bottom: $tableOutsideBorder; + } + + &:not([dir='rtl']) .echLegendTable__cell:last-child { + padding-right: $euiSizeXS/2; + } + + &[dir='rtl'] { + .echLegendTable__cell:last-child { + padding-left: $euiSizeXS/2; + } + + .echLegendItem { + &__label { + text-align: right; + } + } + .echLegend__legendValue { + text-align: left; + } + } + + &--highlightable { + .echLegendTable__cell:hover { + background-color: $tableRowHoverColor; + } + + .echLegendTable__cell:hover ~ .echLegendTable__cell { + background-color: $tableRowHoverColor; + } + + .echLegendTable__cell:has(~ .echLegendTable__cell:hover) { + background-color: $tableRowHoverColor; + } + } + + &:not(&--hidden) { + .echLegendSingleItem__color--changable { + cursor: pointer; + } + } + + &--vertical { + padding-top: $legendItemVerticalPadding / 2; + padding-bottom: $legendItemVerticalPadding / 2; + } + + &--hidden { + color: $euiColorDarkShade; + } + + .echLegend__legendValue { + @include euiFontSizeXS; + text-align: right; + font-feature-settings: 'tnum'; + letter-spacing: unset; + direction: ltr; + white-space: nowrap; + } +} diff --git a/packages/charts/src/components/legend/legend_table/_legend_table.scss b/packages/charts/src/components/legend/legend_table/_legend_table.scss new file mode 100644 index 0000000000..8187790c06 --- /dev/null +++ b/packages/charts/src/components/legend/legend_table/_legend_table.scss @@ -0,0 +1,49 @@ +@import '../variables'; +@import '../../mixins'; + +.echLegendTable__container { + @include euiYScrollWithShadows; + width: 100%; + overflow-y: auto; + overflow-x: hidden; + + :focus { + @include euiFocusRing(null, 1); + background-color: $euiFocusBackgroundColor; + border-radius: $euiBorderRadius / 2; + } +} + +.echLegendTable { + overflow: auto; + display: grid; + width: 100%; + position: relative; + + &__header, + &__rowgroup, + &__row { + // This ignores all above elements for positioning + // effectively spreading all children in its place + display: contents; + } + + .echColorPickerPopover { + display: flex; + align-items: center; + } +} + +.echLegend { + &--vertical { + .echLegendTable__container { + padding: $echLegendTablePadding; + } + } + + &--horizontal { + .echLegendTable { + padding: $echLegendHorizontalTablePadding; + } + } +} diff --git a/packages/charts/src/components/legend/legend_table/_legend_table_cell.scss b/packages/charts/src/components/legend/legend_table/_legend_table_cell.scss new file mode 100644 index 0000000000..0eb9ae586e --- /dev/null +++ b/packages/charts/src/components/legend/legend_table/_legend_table_cell.scss @@ -0,0 +1,27 @@ +@import '../variables'; +@import '../../mixins'; + +.echLegendTable__cell { + @include euiFontSizeXS; + align-content: baseline; + border-bottom: $tableBorder; + padding: $euiSizeXS ($euiSizeXS * 2); + + &--truncate { + @include lineClamp(1); + } +} + +.echLegendTable__colorCell { + padding-right: 0; + padding-left: $euiSizeXS / 2; + + &.echLegend__colorWrapper { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-start; + z-index: 1; + } +} diff --git a/packages/charts/src/components/legend/legend_table/_legend_table_header.scss b/packages/charts/src/components/legend/legend_table/_legend_table_header.scss new file mode 100644 index 0000000000..e68f000841 --- /dev/null +++ b/packages/charts/src/components/legend/legend_table/_legend_table_header.scss @@ -0,0 +1,12 @@ +@import '../variables'; + +.echLegendTable__header { + cursor: default; + font-weight: $euiFontWeightSemiBold; + background-color: $euiColorEmptyShade; + font-size: $euiFontSizeXS; + + .echLegendTable__cell { + border-bottom: $tableOutsideBorder; + } +} diff --git a/packages/charts/src/components/legend/legend_table/index.tsx b/packages/charts/src/components/legend/legend_table/index.tsx new file mode 100644 index 0000000000..f36b4d1ccc --- /dev/null +++ b/packages/charts/src/components/legend/legend_table/index.tsx @@ -0,0 +1,9 @@ +/* + * 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. + */ + +export { LegendTable } from './legend_table'; diff --git a/packages/charts/src/components/legend/legend_table/legend_table.test.tsx b/packages/charts/src/components/legend/legend_table/legend_table.test.tsx new file mode 100644 index 0000000000..56db9a517f --- /dev/null +++ b/packages/charts/src/components/legend/legend_table/legend_table.test.tsx @@ -0,0 +1,52 @@ +/* + * 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 { mount } from 'enzyme'; +import React from 'react'; + +import { LegendTable } from './legend_table'; +import { ScaleType } from '../../../scales/constants'; +import { Settings, BarSeries } from '../../../specs'; +import { Chart } from '../../chart'; +import { LegendTableRow } from '../legend_table/legend_table_row'; + +describe('Legend', () => { + const renderChartWithLegendTable = (legendValues = ['min' as const, 'max' as const]) => { + const wrapper = mount( + + + + , + ); + return wrapper; + }; + it('shall render the all the series names', () => { + const wrapper = renderChartWithLegendTable(); + const legendWrapper = wrapper.find(LegendTable); + expect(legendWrapper.exists).toBeTruthy(); + const legendRows = legendWrapper.find(LegendTableRow); + expect(legendRows.exists).toBeTruthy(); + expect(legendRows).toHaveLength(5); + const expectedTable = ['MinMax', 'first1010', 'second33', 'third88', 'fourth1010']; + expect(legendRows.map((row) => row.text())).toEqual(expectedTable); + }); +}); diff --git a/packages/charts/src/components/legend/legend_table/legend_table.tsx b/packages/charts/src/components/legend/legend_table/legend_table.tsx new file mode 100644 index 0000000000..2e3aaf9653 --- /dev/null +++ b/packages/charts/src/components/legend/legend_table/legend_table.tsx @@ -0,0 +1,59 @@ +/* + * 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 { LegendTableBody } from './legend_table_body'; +import { LegendTableHeader } from './legend_table_header'; +import { LegendItem } from '../../../common/legend'; +import { LayoutDirection } from '../../../utils/common'; +import { SharedLegendItemProps } from '../types'; + +/** @internal */ +export interface LegendTableProps extends SharedLegendItemProps { + items: LegendItem[]; + seriesWidth?: number; +} + +/** @internal */ +export const GRID_COLOR_WIDTH = 10; +/** @internal */ +export const GRID_ACTION_WIDTH = 26; +/** @internal */ +export const MIN_LABEL_WIDTH = 24; + +/** @internal */ +export function LegendTable({ items, seriesWidth = MIN_LABEL_WIDTH, ...itemProps }: LegendTableProps) { + const legendValuesLength = items?.[0]?.values.length ? `repeat(${items?.[0]?.values.length}, auto)` : ''; + const actionComponentWidth = itemProps.action ? `${GRID_ACTION_WIDTH}px` : ''; + const gridTemplateColumns = { + vertical: `${GRID_COLOR_WIDTH}px minmax(${seriesWidth}px, 1fr) ${legendValuesLength} ${actionComponentWidth}`, + horizontal: `${GRID_COLOR_WIDTH}px minmax(50%, 1fr) ${legendValuesLength} ${actionComponentWidth}`, + }; + return ( +
      + + +
      + ); +} diff --git a/packages/charts/src/components/legend/legend_table/legend_table_body.tsx b/packages/charts/src/components/legend/legend_table/legend_table_body.tsx new file mode 100644 index 0000000000..437b2dcd5e --- /dev/null +++ b/packages/charts/src/components/legend/legend_table/legend_table_body.tsx @@ -0,0 +1,24 @@ +/* + * 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 { LegendListItem } from './legend_table_item'; +import { LegendItem } from '../../../common/legend'; +import { SharedLegendItemProps } from '../types'; + +/** @internal */ +export const LegendTableBody: React.FC = ({ items, ...itemProps }) => { + return ( +
      + {items.map((item) => ( + + ))} +
      + ); +}; diff --git a/packages/charts/src/components/legend/legend_table/legend_table_cell.tsx b/packages/charts/src/components/legend/legend_table/legend_table_cell.tsx new file mode 100644 index 0000000000..4bb4348bb6 --- /dev/null +++ b/packages/charts/src/components/legend/legend_table/legend_table_cell.tsx @@ -0,0 +1,29 @@ +/* + * 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 classNames from 'classnames'; +import React, { PropsWithChildren } from 'react'; + +/** @public */ +export type LegendTableCellProps = PropsWithChildren<{ + truncate?: boolean; + className?: string; +}>; + +/** @public */ +export const LegendTableCell = ({ truncate = false, className, children }: LegendTableCellProps) => { + const classes = classNames('echLegendTable__cell', className, { + 'echLegendTable__cell--truncate': truncate, + }); + + return ( +
      + {children} +
      + ); +}; diff --git a/packages/charts/src/components/legend/legend_table/legend_table_header.tsx b/packages/charts/src/components/legend/legend_table/legend_table_header.tsx new file mode 100644 index 0000000000..2d49fb66d1 --- /dev/null +++ b/packages/charts/src/components/legend/legend_table/legend_table_header.tsx @@ -0,0 +1,54 @@ +/* + * 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 { LegendTableCell } from './legend_table_cell'; +import { LegendTableRow } from './legend_table_row'; +import { LegendValueComponent } from './legend_value'; +import { LegendValue, legendValueTitlesMap } from '../../../common/legend'; +import { LegendLabelOptions } from '../../../utils/themes/theme'; +import { NonInteractiveLabel } from '../label'; + +/** @internal */ +export interface LegendHeaderProps { + legendValues: Array; + hasAction?: boolean; +} + +/** @internal */ +export const LegendTableHeader = ({ + hasAction, + legendValues, + legendTitle = '', + isMostlyRTL, + labelOptions, +}: { + legendValues: LegendValue[]; + hasAction: boolean; + legendTitle?: string; + isMostlyRTL?: boolean; + labelOptions: LegendLabelOptions; +}) => { + return ( +
      + + + + + + {legendValues.map((l) => ( + + + + ))} + {hasAction && } + +
      + ); +}; diff --git a/packages/charts/src/components/legend/legend_table/legend_table_item.tsx b/packages/charts/src/components/legend/legend_table/legend_table_item.tsx new file mode 100644 index 0000000000..fe3ea2684c --- /dev/null +++ b/packages/charts/src/components/legend/legend_table/legend_table_item.tsx @@ -0,0 +1,106 @@ +/* + * 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 classNames from 'classnames'; +import React, { useCallback } from 'react'; + +import { LegendTableCell } from './legend_table_cell'; +import { LegendTableRow } from './legend_table_row'; +import { LegendValueComponent } from './legend_value'; +import { SeriesIdentifier } from '../../../common/series_id'; +import { LayoutDirection } from '../../../utils/common'; +import { Label as ItemLabel } from '../label'; +import { useLegendColorPicker } from '../legend_color_picker'; +import { LegendItemProps, prepareLegendValues } from '../legend_item'; + +/** @internal */ +export const LegendListItem: React.FC = (props) => { + const { + extraValues, + item, + legendValues, + totalItems, + action: Action, + positionConfig, + labelOptions, + isMostlyRTL, + onClick, + toggleDeselectSeriesAction, + onLegendItemMouseOver, + onLegendItemMouseOut, + hiddenItems, + } = props; + const { color, isSeriesHidden, isItemHidden, seriesIdentifiers, label, path, isToggleable } = item; + + const itemClassNames = classNames('echLegendTable__item', 'echLegendTable__item--highlightable', { + 'echLegendTable__item--hidden': isSeriesHidden, + 'echLegendTable__item--vertical': positionConfig.direction === LayoutDirection.Vertical, + }); + + const legendValueItems = prepareLegendValues(item, legendValues, totalItems, extraValues); + + const onLabelToggle = useCallback( + (legendItemId: SeriesIdentifier[]) => (negate: boolean) => { + if (totalItems <= 1 || (!isToggleable && !onClick)) { + return; + } + + if (onClick) { + onClick(legendItemId); + } + + if (isToggleable) { + toggleDeselectSeriesAction(legendItemId, negate); + } + }, + [onClick, toggleDeselectSeriesAction, isToggleable, totalItems], + ); + + const { renderItemColor, renderColorPickerPopup } = useLegendColorPicker(props); + + if (isItemHidden) return null; + + const ActionComponent = Action ? : null; + + return ( + <> + onLegendItemMouseOver(seriesIdentifiers, path)} + onMouseLeave={onLegendItemMouseOut} + dir={isMostlyRTL ? 'rtl' : 'ltr'} + data-ech-series-name={label} + > + + {renderItemColor()} + + + 1 && item.isToggleable} + onToggle={onLabelToggle(seriesIdentifiers)} + isSeriesHidden={isSeriesHidden} + totalSeriesCount={totalItems} + hiddenSeriesCount={hiddenItems} + /> + + + {legendValueItems.map((l, i) => { + return {l && }; + })} + {ActionComponent && ( + +
      {ActionComponent}
      +
      + )} +
      + {renderColorPickerPopup()} + + ); +}; diff --git a/packages/charts/src/components/legend/legend_table/legend_table_row.tsx b/packages/charts/src/components/legend/legend_table/legend_table_row.tsx new file mode 100644 index 0000000000..da8f8f42c1 --- /dev/null +++ b/packages/charts/src/components/legend/legend_table/legend_table_row.tsx @@ -0,0 +1,23 @@ +/* + * 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 classNames from 'classnames'; +import React from 'react'; + +interface LegendTableRowProps extends React.HTMLAttributes {} + +/** @public */ +export const LegendTableRow = ({ id, children, className, ...rest }: LegendTableRowProps) => { + const classes = classNames('echLegendTable__row', className); + + return ( +
      + {children} +
      + ); +}; diff --git a/packages/charts/src/components/legend/legend_table/legend_value.tsx b/packages/charts/src/components/legend/legend_table/legend_value.tsx new file mode 100644 index 0000000000..a662858dcd --- /dev/null +++ b/packages/charts/src/components/legend/legend_table/legend_value.tsx @@ -0,0 +1,18 @@ +/* + * 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'; + +/** @internal */ +export const LegendValueComponent = ({ label }: { label: string }) => { + return ( +
      + {label} +
      + ); +}; diff --git a/packages/charts/src/components/legend/types.ts b/packages/charts/src/components/legend/types.ts new file mode 100644 index 0000000000..0e3616fccd --- /dev/null +++ b/packages/charts/src/components/legend/types.ts @@ -0,0 +1,46 @@ +/* + * 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 { LegendItemExtraValues, LegendValue } from '../../common/legend'; +import { SeriesIdentifier } from '../../common/series_id'; +import { + LegendItemListener, + BasicListener, + LegendColorPicker, + LegendAction, + LegendPositionConfig, +} from '../../specs/settings'; +import { + clearTemporaryColors as clearTemporaryColorsAction, + setTemporaryColor as setTemporaryColorAction, + setPersistedColor as setPersistedColorAction, +} from '../../state/actions/colors'; +import { LegendPath, onToggleDeselectSeriesAction } from '../../state/actions/legend'; +import { LegendLabelOptions } from '../../utils/themes/theme'; + +/** @internal */ +export interface SharedLegendItemProps { + flatLegend: boolean; + totalItems: number; + positionConfig: LegendPositionConfig; + extraValues: Map; + legendValues: Array; + isMostlyRTL: boolean; + labelOptions: LegendLabelOptions; + colorPicker?: LegendColorPicker; + action?: LegendAction; + onClick?: LegendItemListener; + onLegendItemMouseOver: (seriesIdentifiers: SeriesIdentifier[], path: LegendPath) => void; + onLegendItemMouseOut: BasicListener; + clearTemporaryColorsAction: typeof clearTemporaryColorsAction; + setTemporaryColorAction: typeof setTemporaryColorAction; + setPersistedColorAction: typeof setPersistedColorAction; + toggleDeselectSeriesAction: typeof onToggleDeselectSeriesAction; + legendTitle?: string; + hiddenItems: number; +} diff --git a/packages/charts/src/components/legend/utils.ts b/packages/charts/src/components/legend/utils.ts index 47b7abebe2..6944a2b16a 100644 --- a/packages/charts/src/components/legend/utils.ts +++ b/packages/charts/src/components/legend/utils.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { LegendItemExtraValues, LegendItem, LegendItemValue } from '../../common/legend'; +import { LegendItemExtraValues, LegendItem, LegendItemValue, LegendValue } from '../../common/legend'; /** @internal */ export function getExtra( @@ -17,7 +17,7 @@ export function getExtra( const { seriesIdentifiers, values, childId, path } = item; // don't show extra if the legend item is associated with multiple series if (extraValues.size === 0 || seriesIdentifiers.length > 1 || !seriesIdentifiers[0]) { - return values.length > 0 ? { label: `${values[0]?.label ?? ''}`, value: values[0]?.value ?? null } : undefined; + return values.find((v) => v.type === LegendValue.CurrentAndLastValue || v.type === LegendValue.Value); } const [{ key }] = seriesIdentifiers; const extraValueKey = path.map(({ index }) => index).join('__'); @@ -25,7 +25,7 @@ export function getExtra( const actionExtra = childId !== undefined ? itemExtraValues?.get(childId) : undefined; return actionExtra ? actionExtra - : extraValues.size === totalItems && values.length > 0 - ? { label: `${values[0]?.label ?? ''}`, value: values[0]?.value ?? null } + : extraValues.size === totalItems + ? values.find((v) => v.type === LegendValue.CurrentAndLastValue || v.type === LegendValue.Value) : undefined; } diff --git a/packages/charts/src/specs/settings.tsx b/packages/charts/src/specs/settings.tsx index b0ee71b1ec..6d8e4a58a3 100644 --- a/packages/charts/src/specs/settings.tsx +++ b/packages/charts/src/specs/settings.tsx @@ -468,6 +468,10 @@ export interface LegendSpec { * Override the legend with a custom component. */ customLegend?: CustomLegend; + /** + * a title for the table legend + */ + legendTitle?: string; } /** 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 0c1565f413..71d58360d6 100644 --- a/packages/charts/src/state/selectors/get_legend_config_selector.ts +++ b/packages/charts/src/state/selectors/get_legend_config_selector.ts @@ -29,6 +29,7 @@ export const getLegendConfigSelector = createCustomCachedSelector( onLegendItemOver, onLegendItemPlusClick, legendValues, + legendTitle, }) => { return { flatLegend, @@ -46,6 +47,7 @@ export const getLegendConfigSelector = createCustomCachedSelector( onLegendItemOver, onLegendItemPlusClick, legendValues, + legendTitle, }; }, ); diff --git a/packages/charts/src/state/selectors/get_legend_size.ts b/packages/charts/src/state/selectors/get_legend_size.ts index 3ea9486025..52aa95cb57 100644 --- a/packages/charts/src/state/selectors/get_legend_size.ts +++ b/packages/charts/src/state/selectors/get_legend_size.ts @@ -9,7 +9,9 @@ import { getChartThemeSelector } from './get_chart_theme'; import { getLegendConfigSelector } from './get_legend_config_selector'; import { getLegendItemsSelector } from './get_legend_items'; +import { getLegendTableSize } from './get_legend_table_size'; import { DEFAULT_FONT_FAMILY } from '../../common/default_theme_attributes'; +import { shouldDisplayTable } from '../../common/legend'; import { LEGEND_HIERARCHY_MARGIN } from '../../components/legend/legend_item'; import { LEGEND_TO_FULL_CONFIG } from '../../components/legend/position_style'; import { LegendPositionConfig } from '../../specs/settings'; @@ -21,30 +23,33 @@ import { createCustomCachedSelector } from '../create_selector'; const getParentDimensionSelector = (state: GlobalChartState) => state.parentDimensions; -const SCROLL_BAR_WIDTH = 16; // ~1em -const MARKER_WIDTH = 16; +/** @internal */ +export const SCROLL_BAR_WIDTH = 16; // ~1em +/** @internal */ +export const MARKER_WIDTH = 16; const SHARED_MARGIN = 4; const VERTICAL_PADDING = 4; -const TOP_MARGIN = 2; +/** @internal */ +export const TOP_MARGIN = 2; /** @internal */ export type LegendSizing = Size & { margin: number; position: LegendPositionConfig; + seriesWidth?: number; }; /** @internal */ export const getLegendSizeSelector = createCustomCachedSelector( [getLegendConfigSelector, getChartThemeSelector, getParentDimensionSelector, getLegendItemsSelector], - ( - { showLegend, legendSize, legendValues, legendPosition, legendAction }, - theme, - parentDimensions, - items, - ): LegendSizing => { + (config, theme, parentDimensions, items): LegendSizing => { + const { showLegend, legendSize, legendValues, legendPosition, legendAction } = config; if (!showLegend) { return { width: 0, height: 0, margin: 0, position: LEGEND_TO_FULL_CONFIG[Position.Right] }; } + if (shouldDisplayTable(legendValues)) { + return withTextMeasure((textMeasure) => getLegendTableSize(config, theme, parentDimensions, items, textMeasure)); + } const bbox = withTextMeasure((textMeasure) => items.reduce( diff --git a/packages/charts/src/state/selectors/get_legend_table_size.ts b/packages/charts/src/state/selectors/get_legend_table_size.ts new file mode 100644 index 0000000000..dbcc552f60 --- /dev/null +++ b/packages/charts/src/state/selectors/get_legend_table_size.ts @@ -0,0 +1,124 @@ +/* + * 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 { getLegendConfigSelector } from './get_legend_config_selector'; +import { LegendSizing } from './get_legend_size'; +import { DEFAULT_FONT_FAMILY } from '../../common/default_theme_attributes'; +import { LegendItem, LegendItemValue, legendValueTitlesMap } from '../../common/legend'; +import { Font } from '../../common/text_utils'; +import { + GRID_ACTION_WIDTH, + GRID_COLOR_WIDTH, + MIN_LABEL_WIDTH, +} from '../../components/legend/legend_table/legend_table'; +import { TextMeasure } from '../../utils/bbox/canvas_text_bbox_calculator'; +import { isDefined, LayoutDirection } from '../../utils/common'; +import { Dimensions } from '../../utils/dimensions'; +import { Theme } from '../../utils/themes/theme'; + +const MONO_LETTER_WIDTH = 7.8; +const MONO_SEPARATOR_WIDTH = 4.5; + +const SCROLL_BAR_WIDTH = 16; // ~1em +const VERTICAL_PADDING = 4; +const TOP_MARGIN = 2; + +const GRID_CELL_PADDING = { height: 4, width: 8 }; +const GRID_MARGIN = 8; +const HORIZONTAL_VISIBLE_LINES_NUMBER = 4; +const GRID_CELL_BORDER_WIDTH = 1; + +const fontArgs: [Font, number, number] = [ + { + fontFamily: DEFAULT_FONT_FAMILY, + fontWeight: 400, + fontVariant: 'normal', + fontStyle: 'normal', + textColor: 'black', + }, + 12, + 1.34, +]; + +const headerFontArgs: [Font, number, number] = [ + { + ...fontArgs[0], + fontWeight: 600, + }, + fontArgs[1], + fontArgs[2], +]; + +const calcApprValuesWidth = ({ label }: LegendItemValue) => + label.includes('.') + ? (label.length - 1) * MONO_LETTER_WIDTH + MONO_SEPARATOR_WIDTH + : label.length * MONO_LETTER_WIDTH; + +/** @internal */ +export function getLegendTableSize( + config: ReturnType, + theme: Theme, + parentDimensions: Dimensions, + items: LegendItem[], + textMeasure: TextMeasure, +): LegendSizing { + const { + legend: { verticalWidth, spacingBuffer, margin }, + } = theme; + + const { legendSize, legendValues, legendPosition, legendAction } = config; + + const { width: titleWidth, height } = textMeasure(config.legendTitle || '', ...headerFontArgs); + const valuesTitlesWidth = legendValues.map((v) => textMeasure(legendValueTitlesMap[v], ...fontArgs).width); + + const widestLabelWidth = items.reduce( + (acc, { label }) => Math.max(acc, textMeasure(label, ...fontArgs).width), + Math.max(titleWidth, MIN_LABEL_WIDTH), + ); + + const widestValuesWidths = items.reduce((acc, { values }) => { + const valuesWidths = values.map(calcApprValuesWidth); + return acc.map((w, i) => Math.ceil(Math.max(w, valuesWidths[i] || 0))); + }, valuesTitlesWidth); + + const seriesWidth = Math.ceil(widestLabelWidth + GRID_CELL_PADDING.width * 2); + + const legendItemWidth = + seriesWidth + widestValuesWidths.reduce((acc, w) => acc + w + GRID_CELL_PADDING.width * 2, 0) + 1; + + const actionWidth = isDefined(legendAction) ? GRID_ACTION_WIDTH : 0; + + if (legendPosition.direction === LayoutDirection.Vertical) { + const maxAvailableWidth = parentDimensions.width * 0.5; + const legendItemHeight = height + VERTICAL_PADDING * 2; + const legendHeight = legendItemHeight * items.length + TOP_MARGIN; + const scrollBarDimension = legendHeight > parentDimensions.height ? SCROLL_BAR_WIDTH : 0; + const staticWidth = GRID_COLOR_WIDTH + GRID_MARGIN * 2 + actionWidth + scrollBarDimension; + + const width = Number.isFinite(legendSize) + ? Math.min(Math.max(legendSize, legendItemWidth * 0.3 + staticWidth), maxAvailableWidth) + : Math.ceil(Math.min(legendItemWidth + staticWidth, maxAvailableWidth)); + + return { + height: legendHeight, + width, + margin, + position: legendPosition, + seriesWidth: Math.min(seriesWidth, (Number.isFinite(legendSize) ? legendSize : maxAvailableWidth) / 2), + }; + } + + const visibleLinesNumber = Math.min(items.length + 1, HORIZONTAL_VISIBLE_LINES_NUMBER); + const singleLineHeight = height + GRID_CELL_PADDING.height * 2 + GRID_CELL_BORDER_WIDTH; + return { + height: singleLineHeight * visibleLinesNumber + GRID_MARGIN, + width: Math.floor(Math.min(legendItemWidth + spacingBuffer + actionWidth, verticalWidth)), + margin, + position: legendPosition, + }; +} diff --git a/packages/charts/src/utils/data_samples/test_dataset.ts b/packages/charts/src/utils/data_samples/test_dataset.ts index c7693f9a51..de75d424f7 100644 --- a/packages/charts/src/utils/data_samples/test_dataset.ts +++ b/packages/charts/src/utils/data_samples/test_dataset.ts @@ -162,3 +162,164 @@ export const TIME_CHART_2Y0G = [ { x: NOW + DAY * 2, y1: 1, y2: 2 }, { x: NOW + DAY * 3, y1: 6, y2: 10 }, ]; + +export const SHORT_NAMES_BARCHART = [ + { x: 0, g: 'a', y: 1000 }, + { x: 0, g: 'b', y: 1 }, + { x: 0, g: 'c', y: 3 }, + { x: 0, g: 'd', y: 3 }, + { x: 1, g: 'e', y: 2 }, + { x: 1, g: 'f', y: 2 }, + { x: 1, g: 'g', y: 2 }, + { x: 1, g: 'h', y: 2 }, + { x: 2, g: 'i', y: 10 }, + { x: 2, g: 'j', y: 10 }, + { x: 2, g: 'k', y: 3 }, + { x: 2, g: 'l', y: 3 }, + { x: 3, g: 'm', y: 7 }, + { x: 3, g: 'n', y: 7 }, + { x: 3, g: 'o', y: 6 }, +]; + +export const LONG_NAMES_BARCHART_2Y2G = [ + { + x: 0, + g1: 'cdn.extremelylongdomainnameforexampletest.com', + g2: 'very-long-indirect-cdn-service-name-test-purpose-only', + y1: 1, + y2: 4, + }, + { + x: 0, + g1: 'cdn.extremelylongdomainnameforexampletest.com', + g2: 'another-extremely-long-indirect-cdn-service-name-used-for-testing', + y1: 1, + y2: 4, + }, + { + x: 0, + g1: 'quitelongdomainnamefortestingpurpose.com', + g2: 'very-long-direct-cdn-service-name-test-purpose-only', + y1: 3, + y2: 6, + }, + { + x: 0, + g1: 'quitelongdomainnamefortestingpurpose.com', + g2: 'another-extremely-long-direct-cdn-service-name-used-for-testing', + y1: 3, + y2: 6, + }, + { + x: 1, + g1: 'cdn.extremelylongdomainnameforexampletest.com', + g2: 'very-long-indirect-cdn-service-name-test-purpose-only', + y1: 2, + y2: 1, + }, + { + x: 1, + g1: 'cdn.extremelylongdomainnameforexampletest.com', + g2: 'another-extremely-long-indirect-cdn-service-name-used-for-testing', + y1: 2, + y2: 1, + }, + { + x: 1, + g1: 'quitelongdomainnamefortestingpurpose.com', + g2: 'very-long-direct-cdn-service-name-test-purpose-only', + y1: 2, + y2: 5, + }, + { + x: 1, + g1: 'quitelongdomainnamefortestingpurpose.com', + g2: 'another-extremely-long-direct-cdn-service-name-used-for-testing', + y1: 2, + y2: 5, + }, + { + x: 2, + g1: 'cdn.extremelylongdomainnameforexampletest.com', + g2: 'very-long-indirect-cdn-service-name-test-purpose-only', + y1: 10, + y2: 5, + }, + { + x: 2, + g1: 'cdn.extremelylongdomainnameforexampletest.com', + g2: 'another-extremely-long-indirect-cdn-service-name-used-for-testing', + y1: 10, + y2: 5, + }, + { + x: 2, + g1: 'quitelongdomainnamefortestingpurpose.com', + g2: 'very-long-direct-cdn-service-name-test-purpose-only', + y1: 3, + y2: 1, + }, + { + x: 2, + g1: 'quitelongdomainnamefortestingpurpose.com', + g2: 'another-extremely-long-direct-cdn-service-name-used-for-testing', + y1: 3, + y2: 1, + }, + { + x: 3, + g1: 'cdn.extremelylongdomainnameforexampletest.com', + g2: 'very-long-indirect-cdn-service-name-test-purpose-only', + y1: 7, + y2: 3, + }, + { + x: 3, + g1: 'cdn.extremelylongdomainnameforexampletest.com', + g2: 'another-extremely-long-indirect-cdn-service-name-used-for-testing', + y1: 7, + y2: 3, + }, + { + x: 3, + g1: 'quitelongdomainnamefortestingpurpose.com', + g2: 'very-long-direct-cdn-service-name-test-purpose-only', + y1: 6, + y2: 4, + }, + { + x: 3, + g1: 'quitelongdomainnamefortestingpurpose.com', + g2: 'another-extremely-long-direct-cdn-service-name-used-for-testing', + y1: 6, + y2: 4, + }, + { + x: 6, + g1: 'cdn.extremelylongdomainnameforexampletest.com', + g2: 'very-long-indirect-cdn-service-name-test-purpose-only', + y1: 7, + y2: 3, + }, + { + x: 6, + g1: 'cdn.extremelylongdomainnameforexampletest.com', + g2: 'another-extremely-long-indirect-cdn-service-name-used-for-testing', + y1: 7, + y2: 3, + }, + { + x: 6, + g1: 'quitelongdomainnamefortestingpurpose.com', + g2: 'very-long-direct-cdn-service-name-test-purpose-only', + y1: 6, + y2: 4, + }, + { + x: 6, + g1: 'quitelongdomainnamefortestingpurpose.com', + g2: 'another-extremely-long-direct-cdn-service-name-used-for-testing', + y1: 6, + y2: 4, + }, +]; diff --git a/storybook/stories/legend/11_legend_actions.story.tsx b/storybook/stories/legend/11_legend_actions.story.tsx index 618fb601f8..9f19a47668 100644 --- a/storybook/stories/legend/11_legend_actions.story.tsx +++ b/storybook/stories/legend/11_legend_actions.story.tsx @@ -42,7 +42,7 @@ export const Example: ChartsStory = (_, { title, description }) => { const legendPosition = customKnobs.enum.position('Legend position', undefined, { group: 'Legend' }); const euiPopoverPosition = customKnobs.enum.euiPopoverPosition(undefined, undefined, { group: 'Legend' }); const legendValues = customKnobs.multiSelect( - 'LegendValue', + 'Legend Value', LegendValue, LegendValue.CurrentAndLastValue, 'multi-select', diff --git a/storybook/stories/legend/17_tabular_data.story.tsx b/storybook/stories/legend/17_tabular_data.story.tsx new file mode 100644 index 0000000000..f12c6d2f83 --- /dev/null +++ b/storybook/stories/legend/17_tabular_data.story.tsx @@ -0,0 +1,118 @@ +/* + * 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 { boolean, number, select, text } from '@storybook/addon-knobs'; +import React from 'react'; + +import { + Axis, + BarSeries, + Chart, + Position, + ScaleType, + Settings, + LegendLabelOptions, + LegendValue, +} from '@elastic/charts'; +import * as TestDatasets from '@elastic/charts/src/utils/data_samples/test_dataset'; + +import { getLegendSizeKnob } from './legend_size_knob'; +import { ChartsStory } from '../../types'; +import { useBaseTheme } from '../../use_base_theme'; +import { getColorPicker } from '../utils/components/get_color_picker'; +import { getLegendAction } from '../utils/components/get_legend_action'; +import { customKnobs } from '../utils/knobs'; + +const getLabelOptionKnobs = (): LegendLabelOptions => { + const group = 'Label options'; + + return { + maxLines: number('max label lines', 1, { min: 0, step: 1 }, group), + }; +}; + +const defaultDataset = { + xAccessor: 'x', + yAccessors: ['y1', 'y2'], + splitSeriesAccessors: ['g1', 'g2'], + data: TestDatasets.BARCHART_2Y2G, +}; + +const datasets: Record<'defaultDataset' | 'shortCopyDataset' | 'longCopyDataset', any> = { + defaultDataset, + shortCopyDataset: { + xAccessor: 'x', + yAccessors: ['y'], + splitSeriesAccessors: ['g'], + data: TestDatasets.SHORT_NAMES_BARCHART, + }, + longCopyDataset: { + ...defaultDataset, + data: TestDatasets.LONG_NAMES_BARCHART_2Y2G, + }, +}; + +export const Example: ChartsStory = (_, { title, description }) => { + const hideActions = boolean('Hide legend action', false, 'Legend'); + const showLegendExtra = !boolean('Hide legend extra', false, 'Legend'); + const showColorPicker = !boolean('Hide color picker', true, 'Legend'); + const legendPosition = customKnobs.enum.position('Legend position', undefined, { group: 'Legend' }); + const euiPopoverPosition = customKnobs.enum.euiPopoverPosition(undefined, undefined, { group: 'Legend' }); + const legendTitle = text('Legend title', '', 'Legend'); + + const legendValues = customKnobs.multiSelect( + 'Legend Value', + LegendValue, + [LegendValue.Median, LegendValue.Min, LegendValue.Max], + 'multi-select', + 'Legend', + ); + const labelOptions = getLabelOptionKnobs(); + const numberFormattingPrecision = number('Number formatting precision', 2, { min: 0, step: 1 }, 'Legend'); + + const datasetSelect = select( + 'Dataset', + { + default: 'defaultDataset', + 'short copy': 'shortCopyDataset', + 'long copy': 'longCopyDataset', + }, + 'defaultDataset', + 'Legend', + ); + + return ( + + + + Number(d).toFixed(numberFormattingPrecision)} + /> + + + + ); +}; + +Example.parameters = { + resize: true, + markdown: 'This story shows a bar chart with different legend values to test.', +}; diff --git a/storybook/stories/legend/legend.stories.tsx b/storybook/stories/legend/legend.stories.tsx index febcffb6fa..7406b5843b 100644 --- a/storybook/stories/legend/legend.stories.tsx +++ b/storybook/stories/legend/legend.stories.tsx @@ -23,3 +23,4 @@ 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'; +export { Example as tabularData } from './17_tabular_data.story'; diff --git a/storybook/stories/utils/components/get_color_picker.tsx b/storybook/stories/utils/components/get_color_picker.tsx index f2012fcc85..35ec303d7c 100644 --- a/storybook/stories/utils/components/get_color_picker.tsx +++ b/storybook/stories/utils/components/get_color_picker.tsx @@ -28,6 +28,7 @@ export const getColorPicker = closePopover={onClose} panelStyle={{ padding: 16 }} anchorPosition={anchorPosition} + className="echColorPickerPopover" ownFocus >