From dfd5d7b9a1a1f5c25c0b131bdf1c31f73ce1eb4c Mon Sep 17 00:00:00 2001 From: Nick Partridge Date: Wed, 2 Oct 2019 09:47:54 -0500 Subject: [PATCH] feat(tooltip): tooltip label format for upper/lower banded area series (#391) Allow users to set a formatted label for upper and lower bound of banded series to distinguish between series in tooltip. Format allows a postfix string or accessor function. closes #162 --- .../xy_chart/rendering/rendering.ts | 57 +++++++----- src/chart_types/xy_chart/store/utils.test.ts | 6 +- src/chart_types/xy_chart/store/utils.ts | 5 +- .../xy_chart/tooltip/tooltip.test.ts | 93 +++++++++++++++++++ src/chart_types/xy_chart/tooltip/tooltip.ts | 29 +++--- src/chart_types/xy_chart/utils/specs.ts | 18 +++- src/utils/accessor.ts | 16 ++++ stories/annotations.tsx | 9 +- stories/area_chart.tsx | 6 +- 9 files changed, 190 insertions(+), 49 deletions(-) diff --git a/src/chart_types/xy_chart/rendering/rendering.ts b/src/chart_types/xy_chart/rendering/rendering.ts index 4a85824c51..0991b29774 100644 --- a/src/chart_types/xy_chart/rendering/rendering.ts +++ b/src/chart_types/xy_chart/rendering/rendering.ts @@ -25,10 +25,20 @@ export interface GeometryId { seriesKey: any[]; } +/** + * The accessor type + */ +export const AccessorType = Object.freeze({ + Y0: 'y0' as 'y0', + Y1: 'y1' as 'y1', +}); + +export type AccessorType = typeof AccessorType.Y0 | typeof AccessorType.Y1; + export interface GeometryValue { y: any; x: any; - accessor: 'y1' | 'y0'; + accessor: AccessorType; } /** Shared style properties for varies geometries */ @@ -187,19 +197,18 @@ export function renderPoints( const isLogScale = isLogarithmicScale(yScale); const pointGeometries = dataset.reduce( (acc, datum) => { + const { x: xValue, y0, y1, initialY0, initialY1 } = datum; // don't create the point if not within the xScale domain - if (!xScale.isValueInDomain(datum.x)) { + if (!xScale.isValueInDomain(xValue)) { return acc; } - const x = xScale.scale(datum.x); + const x = xScale.scale(xValue); const points: PointGeometry[] = []; - const yDatums = [datum.y1]; - if (hasY0Accessors) { - yDatums.unshift(datum.y0); - } + const yDatums = hasY0Accessors ? [y0, y1] : [y1]; + yDatums.forEach((yDatum, index) => { // skip rendering point if y1 is null - if (datum.y1 === null) { + if (y1 === null) { return; } let y; @@ -212,7 +221,7 @@ export function renderPoints( } else { y = yScale.scale(yDatum); } - const originalY = hasY0Accessors && index === 0 ? datum.initialY0 : datum.initialY1; + const originalY = hasY0Accessors && index === 0 ? initialY0 : initialY1; const geometryId = { specId, seriesKey, @@ -224,9 +233,9 @@ export function renderPoints( y, color, value: { - x: datum.x, + x: xValue, y: originalY, - accessor: hasY0Accessors && index === 0 ? 'y0' : 'y1', + accessor: hasY0Accessors && index === 0 ? AccessorType.Y0 : AccessorType.Y1, }, transform: { x: shift, @@ -235,7 +244,7 @@ export function renderPoints( geometryId, styleOverrides, }; - mutableIndexedGeometryMapUpsert(indexedGeometries, datum.x, pointGeometry); + mutableIndexedGeometryMapUpsert(indexedGeometries, xValue, pointGeometry); // use the geometry only if the yDatum in contained in the current yScale domain if (!isHidden && yScale.isValueInDomain(yDatum)) { points.push(pointGeometry); @@ -358,7 +367,7 @@ export function renderBars( value: { x: datum.x, y: initialY1, - accessor: 'y1', + accessor: AccessorType.Y1, }, geometryId, seriesStyle, @@ -395,10 +404,10 @@ export function renderLine( const isLogScale = isLogarithmicScale(yScale); const pathGenerator = line() - .x((datum: DataSeriesDatum) => xScale.scale(datum.x) - xScaleOffset) - .y((datum: DataSeriesDatum) => yScale.scale(datum.y1)) - .defined((datum: DataSeriesDatum) => { - return datum.y1 !== null && !(isLogScale && datum.y1 <= 0) && xScale.isValueInDomain(datum.x); + .x(({ x }) => xScale.scale(x) - xScaleOffset) + .y(({ y1 }) => yScale.scale(y1)) + .defined(({ x, y1 }) => { + return y1 !== null && !(isLogScale && y1 <= 0) && xScale.isValueInDomain(x); }) .curve(getCurveFactory(curve)); const y = 0; @@ -458,16 +467,16 @@ export function renderArea( const isLogScale = isLogarithmicScale(yScale); const pathGenerator = area() - .x((datum: DataSeriesDatum) => xScale.scale(datum.x) - xScaleOffset) - .y1((datum: DataSeriesDatum) => yScale.scale(datum.y1)) - .y0((datum: DataSeriesDatum) => { - if (datum.y0 === null || (isLogScale && datum.y0 <= 0)) { + .x(({ x }) => xScale.scale(x) - xScaleOffset) + .y1(({ y1 }) => yScale.scale(y1)) + .y0(({ y0 }) => { + if (y0 === null || (isLogScale && y0 <= 0)) { return yScale.range[0]; } - return yScale.scale(datum.y0); + return yScale.scale(y0); }) - .defined((datum: DataSeriesDatum) => { - return datum.y1 !== null && !(isLogScale && datum.y1 <= 0) && xScale.isValueInDomain(datum.x); + .defined(({ y1, x }) => { + return y1 !== null && !(isLogScale && y1 <= 0) && xScale.isValueInDomain(x); }) .curve(getCurveFactory(curve)); diff --git a/src/chart_types/xy_chart/store/utils.test.ts b/src/chart_types/xy_chart/store/utils.test.ts index 06a22df80b..f3bbc4c8e6 100644 --- a/src/chart_types/xy_chart/store/utils.test.ts +++ b/src/chart_types/xy_chart/store/utils.test.ts @@ -1,5 +1,5 @@ import { mergeYCustomDomainsByGroupId } from '../utils/axis_utils'; -import { IndexedGeometry } from '../rendering/rendering'; +import { IndexedGeometry, AccessorType } from '../rendering/rendering'; import { DataSeriesColorsValues, findDataSeriesByColorValues, getSeriesColorMap } from '../utils/series'; import { AreaSeriesSpec, @@ -1009,7 +1009,7 @@ describe('Chart State utils', () => { x: 0, y: 0, color: '#1EA593', - value: { x: 0, y: 5, accessor: 'y1' }, + value: { x: 0, y: 5, accessor: AccessorType.Y1 }, transform: { x: 0, y: 0 }, geometryId: { specId: getSpecId('line1'), seriesKey: [] }, }, @@ -1021,7 +1021,7 @@ describe('Chart State utils', () => { x: 0, y: 175.8, color: '#2B70F7', - value: { x: 0, y: 2, accessor: 'y1' }, + value: { x: 0, y: 2, accessor: AccessorType.Y1 }, transform: { x: 0, y: 0 }, geometryId: { specId: getSpecId('line2'), seriesKey: [] }, }, diff --git a/src/chart_types/xy_chart/store/utils.ts b/src/chart_types/xy_chart/store/utils.ts index 151b51cca7..2c64700d9b 100644 --- a/src/chart_types/xy_chart/store/utils.ts +++ b/src/chart_types/xy_chart/store/utils.ts @@ -35,6 +35,7 @@ import { isLineSeriesSpec, LineSeriesSpec, Rotation, + isBandedSpec, } from '../utils/specs'; import { ColorConfig, Theme } from '../../../utils/themes/theme'; import { identity, mergePartial } from '../../../utils/commons'; @@ -474,7 +475,7 @@ export function renderGeometries( color, (spec as LineSeriesSpec).curve || CurveType.LINEAR, ds.specId, - Boolean(spec.y0Accessors), + isBandedSpec(spec.y0Accessors), ds.key, xScaleOffset, lineSeriesStyle, @@ -500,7 +501,7 @@ export function renderGeometries( color, (spec as AreaSeriesSpec).curve || CurveType.LINEAR, ds.specId, - Boolean(spec.y0Accessors), + isBandedSpec(spec.y0Accessors), ds.key, xScaleOffset, areaSeriesStyle, diff --git a/src/chart_types/xy_chart/tooltip/tooltip.test.ts b/src/chart_types/xy_chart/tooltip/tooltip.test.ts index 2181a19ba8..3a80bb57fd 100644 --- a/src/chart_types/xy_chart/tooltip/tooltip.test.ts +++ b/src/chart_types/xy_chart/tooltip/tooltip.test.ts @@ -18,6 +18,10 @@ describe('Tooltip formatting', () => { yScaleType: ScaleType.Linear, xScaleType: ScaleType.Linear, }; + const bandedSpec = { + ...SPEC_1, + y0Accessors: [1], + }; const YAXIS_SPEC: AxisSpec = { id: getAxisId('axis_1'), groupId: SPEC_GROUP_ID_1, @@ -63,6 +67,23 @@ describe('Tooltip formatting', () => { }, seriesStyle, }; + const indexedBandedGeometry: BarGeometry = { + x: 0, + y: 0, + width: 0, + height: 0, + color: 'blue', + geometryId: { + specId: SPEC_ID_1, + seriesKey: [], + }, + value: { + x: 1, + y: 10, + accessor: 'y1', + }, + seriesStyle, + }; test('format simple tooltip', () => { const tooltipValue = formatTooltip(indexedGeometry, SPEC_1, false, false, YAXIS_SPEC); @@ -74,6 +95,78 @@ describe('Tooltip formatting', () => { expect(tooltipValue.color).toBe('blue'); expect(tooltipValue.value).toBe('10'); }); + test('format banded tooltip - upper', () => { + const tooltipValue = formatTooltip(indexedBandedGeometry, bandedSpec, false, false, YAXIS_SPEC); + expect(tooltipValue.name).toBe('bar_1 - upper'); + }); + test('format banded tooltip - y1AccessorFormat', () => { + const tooltipValue = formatTooltip( + indexedBandedGeometry, + { ...bandedSpec, y1AccessorFormat: ' [max]' }, + false, + false, + YAXIS_SPEC, + ); + expect(tooltipValue.name).toBe('bar_1 [max]'); + }); + test('format banded tooltip - y1AccessorFormat as function', () => { + const tooltipValue = formatTooltip( + indexedBandedGeometry, + { ...bandedSpec, y1AccessorFormat: (label) => `[max] ${label}` }, + false, + false, + YAXIS_SPEC, + ); + expect(tooltipValue.name).toBe('[max] bar_1'); + }); + test('format banded tooltip - lower', () => { + const tooltipValue = formatTooltip( + { + ...indexedBandedGeometry, + value: { + ...indexedBandedGeometry.value, + accessor: 'y0', + }, + }, + bandedSpec, + false, + false, + YAXIS_SPEC, + ); + expect(tooltipValue.name).toBe('bar_1 - lower'); + }); + test('format banded tooltip - y0AccessorFormat', () => { + const tooltipValue = formatTooltip( + { + ...indexedBandedGeometry, + value: { + ...indexedBandedGeometry.value, + accessor: 'y0', + }, + }, + { ...bandedSpec, y0AccessorFormat: ' [min]' }, + false, + false, + YAXIS_SPEC, + ); + expect(tooltipValue.name).toBe('bar_1 [min]'); + }); + test('format banded tooltip - y0AccessorFormat as function', () => { + const tooltipValue = formatTooltip( + { + ...indexedBandedGeometry, + value: { + ...indexedBandedGeometry.value, + accessor: 'y0', + }, + }, + { ...bandedSpec, y0AccessorFormat: (label) => `[min] ${label}` }, + false, + false, + YAXIS_SPEC, + ); + expect(tooltipValue.name).toBe('[min] bar_1'); + }); test('format tooltip with seriesKey name', () => { const geometry: BarGeometry = { ...indexedGeometry, diff --git a/src/chart_types/xy_chart/tooltip/tooltip.ts b/src/chart_types/xy_chart/tooltip/tooltip.ts index f5b6a680ae..726a815700 100644 --- a/src/chart_types/xy_chart/tooltip/tooltip.ts +++ b/src/chart_types/xy_chart/tooltip/tooltip.ts @@ -1,11 +1,12 @@ import { TooltipValue, isFollowTooltipType, TooltipType, TooltipValueFormatter } from '../utils/interactions'; -import { IndexedGeometry, isPointOnGeometry } from '../rendering/rendering'; +import { IndexedGeometry, isPointOnGeometry, AccessorType } from '../rendering/rendering'; import { getColorValuesAsString } from '../utils/series'; -import { AxisSpec, BasicSeriesSpec, Rotation } from '../utils/specs'; +import { AxisSpec, BasicSeriesSpec, Rotation, isBandedSpec } from '../utils/specs'; import { SpecId, AxisId, GroupId } from '../../../utils/ids'; import { getAxesSpecForSpecId } from '../store/utils'; import { Scale } from '../../../utils/scales/scales'; import { Point } from '../store/chart_state'; +import { getAccessorFormatLabel } from '../../../utils/accessor'; export function getSeriesTooltipValues(tooltipValues: TooltipValue[], defaultValue?: string): Map { // map from seriesKey to tooltipValue @@ -25,30 +26,29 @@ export function getSeriesTooltipValues(tooltipValues: TooltipValue[], defaultVal } export function formatTooltip( - searchIndexValue: IndexedGeometry, - spec: BasicSeriesSpec, + { color, value: { x, y, accessor }, geometryId: { seriesKey } }: IndexedGeometry, + { id, name, y0AccessorFormat = ' - lower', y1AccessorFormat = ' - upper', y0Accessors }: BasicSeriesSpec, isXValue: boolean, isHighlighted: boolean, axisSpec?: AxisSpec, ): TooltipValue { - const { id } = spec; - const { - color, - value: { x, y, accessor }, - geometryId: { seriesKey }, - } = searchIndexValue; const seriesKeyAsString = getColorValuesAsString(seriesKey, id); - let name: string | undefined; + let displayName: string | undefined; if (seriesKey.length > 0) { - name = seriesKey.join(' - '); + displayName = seriesKey.join(' - '); } else { - name = spec.name || `${spec.id}`; + displayName = name || `${id}`; + } + + if (isBandedSpec(y0Accessors)) { + const formatter = accessor === AccessorType.Y0 ? y0AccessorFormat : y1AccessorFormat; + displayName = getAccessorFormatLabel(formatter, displayName); } const value = isXValue ? x : y; return { seriesKey: seriesKeyAsString, - name, + name: displayName, value: axisSpec ? axisSpec.tickFormat(value) : emptyFormatter(value), color, isHighlighted: isXValue ? false : isHighlighted, @@ -136,6 +136,7 @@ export function getTooltipAndHighlightFromXValue( return [...acc, formattedTooltip]; }, []); + return { tooltipData, highlightedGeometries, diff --git a/src/chart_types/xy_chart/utils/specs.ts b/src/chart_types/xy_chart/utils/specs.ts index 45d2055d6a..2fbbef7abc 100644 --- a/src/chart_types/xy_chart/utils/specs.ts +++ b/src/chart_types/xy_chart/utils/specs.ts @@ -7,7 +7,7 @@ import { BarSeriesStyle, PointStyle, } from '../../../utils/themes/theme'; -import { Accessor } from '../../../utils/accessor'; +import { Accessor, AccessorFormat } from '../../../utils/accessor'; import { Omit, RecursivePartial } from '../../../utils/commons'; import { AnnotationId, AxisId, GroupId, SpecId } from '../../../utils/ids'; import { ScaleContinuousType, ScaleType } from '../../../utils/scales/scales'; @@ -108,6 +108,18 @@ export interface SeriesSpec { /** Index per series to sort by */ sortIndex?: number; displayValueSettings?: DisplayValueSpec; + /** + * Postfix string or accessor function for y1 accesor when using `y0Accessors` + * + * @default ' - upper' + */ + y0AccessorFormat?: AccessorFormat; + /** + * Postfix string or accessor function for y1 accesor when using `y0Accessors` + * + * @default ' - lower' + */ + y1AccessorFormat?: AccessorFormat; } export type CustomSeriesColorsMap = Map; @@ -413,3 +425,7 @@ export function isLineSeriesSpec(spec: BasicSeriesSpec): spec is LineSeriesSpec export function isAreaSeriesSpec(spec: BasicSeriesSpec): spec is AreaSeriesSpec { return spec.seriesType === 'area'; } + +export function isBandedSpec(y0Accessors: SeriesAccessors['y0Accessors']): boolean { + return Boolean(y0Accessors && y0Accessors.length > 0); +} diff --git a/src/utils/accessor.ts b/src/utils/accessor.ts index 8e127d8235..d0342d255a 100644 --- a/src/utils/accessor.ts +++ b/src/utils/accessor.ts @@ -4,6 +4,11 @@ export type AccessorFn = (datum: Datum) => any; export type AccessorString = string | number; export type Accessor = AccessorString; +/** + * Accessor format for _banded_ series as postfix string or accessor function + */ +export type AccessorFormat = string | ((value: string) => string); + /** * Return an accessor function using the accessor passed as argument * @param accessor the spec accessor @@ -19,3 +24,14 @@ export function getAccessorFn(accessor: Accessor): AccessorFn { } throw new Error('Accessor must be a string or a function'); } + +/** + * Return the accessor label given as `AccessorFormat` + */ +export function getAccessorFormatLabel(accessor: AccessorFormat, label: string): string { + if (typeof accessor === 'string') { + return `${label}${accessor}`; + } + + return accessor(label); +} diff --git a/stories/annotations.tsx b/stories/annotations.tsx index 14d5f6bcf1..b15655c69a 100644 --- a/stories/annotations.tsx +++ b/stories/annotations.tsx @@ -22,6 +22,7 @@ import { } from '../src'; import { Icon } from '../src/components/icons/icon'; import { KIBANA_METRICS } from '../src/utils/data_samples/test_dataset_kibana'; +import { AccessorType } from '../src/chart_types/xy_chart/rendering/rendering'; const dateFormatter = timeFormatter('HH:mm:ss'); @@ -371,8 +372,8 @@ storiesOf('Annotations', module) { x0: 'x0', x1: 'x1', - y0: 'y0', - y1: 'y1', + y0: AccessorType.Y0, + y1: AccessorType.Y1, }, 'x0', ); @@ -400,8 +401,8 @@ storiesOf('Annotations', module) coordinates: { x0: definedCoordinate === 'x0' ? 0.25 : null, x1: definedCoordinate === 'x1' ? 2.75 : null, - y0: definedCoordinate === 'y0' ? 0.25 : null, - y1: definedCoordinate === 'y1' ? 6.75 : null, + y0: definedCoordinate === AccessorType.Y0 ? 0.25 : null, + y1: definedCoordinate === AccessorType.Y1 ? 6.75 : null, }, details: 'can have null values', }, diff --git a/stories/area_chart.tsx b/stories/area_chart.tsx index 51f5aa7668..8a4d8a1f06 100644 --- a/stories/area_chart.tsx +++ b/stories/area_chart.tsx @@ -1,4 +1,4 @@ -import { boolean } from '@storybook/addon-knobs'; +import { boolean, text } from '@storybook/addon-knobs'; import { storiesOf } from '@storybook/react'; import { DateTime } from 'luxon'; import React from 'react'; @@ -429,6 +429,8 @@ storiesOf('Area Chart', module) return [d[0], d[1]]; }); const scaleToDataExtent = boolean('scale to extent', true); + const y0AccessorFormat = text('y0AccessorFormat', ''); + const y1AccessorFormat = text('y1AccessorFormat', ''); return ( @@ -453,6 +455,8 @@ storiesOf('Area Chart', module) xAccessor={'x'} yAccessors={['max']} y0Accessors={['min']} + y1AccessorFormat={y1AccessorFormat || undefined} + y0AccessorFormat={y0AccessorFormat || undefined} data={data} yScaleToDataExtent={scaleToDataExtent} curve={CurveType.CURVE_MONOTONE_X}