diff --git a/.playground/playground.tsx b/.playground/playground.tsx index 3c9f2ee240..0d74cc58c2 100644 --- a/.playground/playground.tsx +++ b/.playground/playground.tsx @@ -17,28 +17,9 @@ * under the License. */ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - import React from 'react'; -import { Example } from '../stories/icicle/01_unix_icicle'; +import { Example } from '../stories/small_multiples/6_heterogeneous_cartesians'; export class Playground extends React.Component { render() { diff --git a/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-small-multiples-alpha-heterogeneous-visually-looks-correct-1-snap.png b/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-small-multiples-alpha-heterogeneous-visually-looks-correct-1-snap.png new file mode 100644 index 0000000000..c4a9efdc8a Binary files /dev/null and b/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-small-multiples-alpha-heterogeneous-visually-looks-correct-1-snap.png differ diff --git a/src/chart_types/xy_chart/utils/series.ts b/src/chart_types/xy_chart/utils/series.ts index f903f38727..45433faa1a 100644 --- a/src/chart_types/xy_chart/utils/series.ts +++ b/src/chart_types/xy_chart/utils/series.ts @@ -19,11 +19,11 @@ import { SeriesIdentifier, SeriesKey } from '../../../common/series_id'; import { ScaleType } from '../../../scales/constants'; -import { GroupBySpec, BinAgg, Direction, XScaleType } from '../../../specs'; +import { BinAgg, Direction, GroupBySpec, XScaleType } from '../../../specs'; import { OrderBy } from '../../../specs/settings'; import { ColorOverrides } from '../../../state/chart_state'; import { Accessor, AccessorFn, getAccessorValue } from '../../../utils/accessor'; -import { Datum, Color, isNil } from '../../../utils/common'; +import { Color, Datum, isNil } from '../../../utils/common'; import { GroupId } from '../../../utils/ids'; import { Logger } from '../../../utils/logger'; import { ColorConfig } from '../../../utils/themes/theme'; @@ -31,8 +31,8 @@ import { groupSeriesByYGroup, isHistogramEnabled, isStackedSpec } from '../domai import { LastValues } from '../state/utils/types'; import { applyFitFunctionToDataSeries } from './fit_function_utils'; import { groupBy } from './group_data_series'; -import { BasicSeriesSpec, SeriesTypes, SeriesSpecs, SeriesNameConfigOptions, StackMode } from './specs'; -import { formatStackedDataSeriesValues, datumXSortPredicate } from './stacked_series_utils'; +import { BasicSeriesSpec, SeriesNameConfigOptions, SeriesSpecs, SeriesTypes, StackMode } from './specs'; +import { datumXSortPredicate, formatStackedDataSeriesValues } from './stacked_series_utils'; /** @internal */ export const SERIES_DELIMITER = ' - '; @@ -115,9 +115,6 @@ export function getSeriesIndex(series: SeriesIdentifier[], target: SeriesIdentif /** * Returns string form of accessor. Uses index when accessor is a function. - * - * @param accessor - * @param index * @internal */ export function getAccessorFieldName(accessor: Accessor | AccessorFn, index: number) { @@ -126,7 +123,7 @@ export function getAccessorFieldName(accessor: Accessor | AccessorFn, index: num /** * Split a dataset into multiple series depending on the accessors. - * Each series is then associated with a key thats belong to its configuration. + * Each series is then associated with a key that belongs to its configuration. * This method removes every data with an invalid x: a string or number value is required * `y` values and `mark` values are casted to number or null. * @internal @@ -329,12 +326,7 @@ function castToNumber(value: any, nonNumericValues: any[]): number | null { return num; } -/** - * Sorts data based on order of xValues - * @param dataSeries - * @param xValues - * @param xScaleType - */ +/** Sorts data based on order of xValues */ const getSortedDataSeries = ( dataSeries: DataSeries[], xValues: Set, @@ -380,14 +372,7 @@ export function getFormattedDataSeries( return [...fittedAndStackedDataSeries, ...nonStackedDataSeries]; } -/** - * - * @param seriesSpecs the map for all the series spec - * @param deselectedDataSeries the array of deselected/hidden data series - * @param enableVislibSeriesSort is optional; if not specified in , - * @param smallMultiples - * @internal - */ +/** @internal */ export function getDataSeriesFromSpecs( seriesSpecs: BasicSeriesSpec[], deselectedDataSeries: SeriesIdentifier[] = [], @@ -518,6 +503,8 @@ function getSortedOrdinalXValues( } } +const BIG_NUMBER = Number.MAX_SAFE_INTEGER; // the sort comparator must yield finite results, can't use infinities + function getSeriesNameFromOptions( options: SeriesNameConfigOptions, { yAccessor, splitAccessors }: XYChartSeriesIdentifier, @@ -530,7 +517,7 @@ function getSeriesNameFromOptions( return ( options.names .slice() - .sort(({ sortIndex: a = Infinity }, { sortIndex: b = Infinity }) => a - b) + .sort(({ sortIndex: a = BIG_NUMBER }, { sortIndex: b = BIG_NUMBER }) => a - b) .map(({ accessor, value, name }) => { const accessorValue = splitAccessors.get(accessor) ?? null; if (accessorValue === value) { @@ -557,41 +544,28 @@ export function getSeriesName( isTooltip: boolean, spec?: BasicSeriesSpec, ): string { - let delimiter = SERIES_DELIMITER; - if (spec && spec.name && typeof spec.name !== 'string') { - let customLabel: string | number | null = null; - if (typeof spec.name === 'function') { - customLabel = spec.name(seriesIdentifier, isTooltip); - } else { - delimiter = spec.name.delimiter ?? delimiter; - customLabel = getSeriesNameFromOptions(spec.name, seriesIdentifier, delimiter); - } - - if (customLabel !== null) { - return customLabel.toString(); - } + const customLabel = + typeof spec?.name === 'function' + ? spec.name(seriesIdentifier, isTooltip) + : typeof spec?.name === 'object' // extract booleans once https://github.com/microsoft/TypeScript/issues/12184 is fixed + ? getSeriesNameFromOptions(spec.name, seriesIdentifier, spec.name.delimiter ?? SERIES_DELIMITER) + : null; + + if (customLabel !== null) { + return customLabel.toString(); } - let name = ''; - const nameKeys = - spec && spec.yAccessors.length > 1 ? seriesIdentifier.seriesKeys : seriesIdentifier.seriesKeys.slice(0, -1); - - // there is one series, the is only one yAccessor, the first part is not null - if (hasSingleSeries || nameKeys.length === 0 || nameKeys[0] == null) { - if (!spec) { - return ''; - } - - if (spec.splitSeriesAccessors && nameKeys.length > 0 && nameKeys[0] != null) { - name = nameKeys.join(delimiter); - } else { - name = typeof spec.name === 'string' ? spec.name : `${spec.id}`; - } - } else { - name = nameKeys.join(delimiter); - } - - return name; + const multipleYAccessors = spec && spec.yAccessors.length > 1; + const nameKeys = multipleYAccessors ? seriesIdentifier.seriesKeys : seriesIdentifier.seriesKeys.slice(0, -1); + const nonZeroLength = nameKeys.length > 0; + + return nonZeroLength && (spec?.splitSeriesAccessors || !hasSingleSeries) + ? nameKeys.join(typeof spec?.name === 'object' ? spec.name.delimiter ?? SERIES_DELIMITER : SERIES_DELIMITER) + : spec === undefined + ? '' + : typeof spec.name === 'string' + ? spec.name + : spec.id; } function getSortIndex({ specSortIndex }: SeriesCollectionValue, total: number): number { @@ -612,12 +586,7 @@ export function getSortedDataSeriesColorsValuesMap( /** * Helper function to get highest override color. - * - * from highest to lowest: `temporary`, `seriesSpec.color` then `persisted` - * - * @param key - * @param customColors - * @param overrides + * From highest to lowest: `temporary`, `seriesSpec.color` then, unless `temporary` is set to `null`, `persisted` */ function getHighestOverride( key: string, @@ -625,32 +594,13 @@ function getHighestOverride( overrides: ColorOverrides, ): Color | undefined { const tempColor: Color | undefined | null = overrides.temporary[key]; - - if (tempColor) { - return tempColor; - } - - const customColor: Color | undefined | null = customColors.get(key); - - if (customColor) { - return customColor; - } - - if (tempColor === null) { - // Use default color when temporary and custom colors are null - return; - } - - return overrides.persisted[key]; + // Unexpected empty `tempColor` string is falsy and falls through, see comment in `export type Color = ...` + // Use default color when temporary and custom colors are null + return tempColor || customColors.get(key) || (tempColor === null ? undefined : overrides.persisted[key]); } /** * Returns color for a series given all color hierarchies - * - * @param seriesCollection - * @param chartColors - * @param customColors - * @param overrides * @internal */ export function getSeriesColors( diff --git a/src/state/chart_state.ts b/src/state/chart_state.ts index 76b70c4ead..58bfd76546 100644 --- a/src/state/chart_state.ts +++ b/src/state/chart_state.ts @@ -26,10 +26,10 @@ import { PrimitiveValue } from '../chart_types/partition_chart/layout/utils/grou import { PartitionState } from '../chart_types/partition_chart/state/chart_state'; import { XYAxisChartState } from '../chart_types/xy_chart/state/chart_state'; import { LegendItem, LegendItemExtraValues } from '../common/legend'; -import { SeriesKey, SeriesIdentifier } from '../common/series_id'; -import { TooltipInfo, TooltipAnchorPosition } from '../components/tooltip/types'; -import { Spec, PointerEvent, DEFAULT_SETTINGS_SPEC } from '../specs'; -import { Color } from '../utils/common'; +import { SeriesIdentifier, SeriesKey } from '../common/series_id'; +import { TooltipAnchorPosition, TooltipInfo } from '../components/tooltip/types'; +import { DEFAULT_SETTINGS_SPEC, PointerEvent, Spec } from '../specs'; +import { Color, keepDistinct } from '../utils/common'; import { Dimensions } from '../utils/dimensions'; import { Logger } from '../utils/logger'; import { Point } from '../utils/point'; @@ -193,7 +193,7 @@ export interface ExternalEventsState { /** @internal */ export interface ColorOverrides { - temporary: Record; + temporary: Record; // null (vs. undefined) means that `overrides.persisted[key]` in `series.ts` not be used persisted: Record; } @@ -295,25 +295,14 @@ export const chartStoreReducer = (chartId: string) => { zIndex: action.zIndex, }; case SPEC_PARSED: - const chartType = findMainChartType(state.specs); - - if (isChartTypeChanged(state, chartType)) { - const internalChartState = initInternalChartState(chartType); - return { - ...state, - specsInitialized: true, - specParsing: false, - chartType, - internalChartState, - }; - } + const chartType = chartTypeFromSpecs(state.specs); return { ...state, specsInitialized: true, specParsing: false, chartType, + internalChartState: state.chartType === chartType ? state.internalChartState : newInternalState(chartType), }; - case SPEC_UNMOUNTED: return { ...state, @@ -321,26 +310,14 @@ export const chartStoreReducer = (chartId: string) => { chartRendered: false, }; case UPSERT_SPEC: - if (!state.specParsing) { - return { - ...state, - specsInitialized: false, - chartRendered: false, - specParsing: true, - specs: { - [DEFAULT_SETTINGS_SPEC.id]: DEFAULT_SETTINGS_SPEC, - [action.spec.id]: action.spec, - }, - }; - } return { ...state, specsInitialized: false, chartRendered: false, - specs: { - ...state.specs, - [action.spec.id]: action.spec, - }, + specParsing: true, + specs: state.specParsing + ? { ...state.specs, [action.spec.id]: action.spec } + : { [DEFAULT_SETTINGS_SPEC.id]: DEFAULT_SETTINGS_SPEC, [action.spec.id]: action.spec }, }; case REMOVE_SPEC: const { [action.id]: specToRemove, ...rest } = state.specs; @@ -369,22 +346,11 @@ export const chartStoreReducer = (chartId: string) => { }; case EXTERNAL_POINTER_EVENT: // discard events from self if any - if (action.event.chartId === chartId) { - return { - ...state, - externalEvents: { - ...state.externalEvents, - pointer: null, - }, - }; - } return { ...state, externalEvents: { ...state.externalEvents, - pointer: { - ...action.event, - }, + pointer: action.event.chartId === chartId ? null : action.event, }, }; case CLEAR_TEMPORARY_COLORS: @@ -407,59 +373,41 @@ export const chartStoreReducer = (chartId: string) => { }, }; case SET_PERSISTED_COLOR: + const { [action.key]: removedPersistedColor, ...otherPersistentColors } = state.colors.persisted; return { ...state, colors: { ...state.colors, - persisted: - action.color !== null - ? { - ...state.colors.persisted, - [action.key]: action.color, - } - : (() => { - const { [action.key]: removed, ...others } = state.colors.persisted; - return others; - })(), + persisted: { + ...otherPersistentColors, + ...(action.color && { [action.key]: action.color }), + }, }, }; default: - if (getInternalIsInitializedSelector(state) !== InitStatus.Initialized) { - return state; - } - return { - ...state, - interactions: interactionsReducer(state.interactions, action, getLegendItemsSelector(state)), - }; + return getInternalIsInitializedSelector(state) === InitStatus.Initialized + ? { + ...state, + interactions: interactionsReducer(state.interactions, action, getLegendItemsSelector(state)), + } + : state; } }; }; -function findMainChartType(specs: SpecList): ChartTypes | null { - const types: Partial> = Object.keys(specs).reduce>>( - (acc, specId) => { - const { chartType } = specs[specId]; - let accumulator = acc[chartType]; - if (accumulator === undefined) { - accumulator = 0; - } else { - accumulator += 1; - } - acc[chartType] = accumulator; - return acc; - }, - {}, - ); - // https://stackoverflow.com/questions/55012174/why-doesnt-object-keys-return-a-keyof-type-in-typescript - const chartTypes = Object.keys(types).filter((type) => type !== ChartTypes.Global); - if (chartTypes.length > 1) { - Logger.warn('Multiple chart type on the same configuration'); +function chartTypeFromSpecs(specs: SpecList): ChartTypes | null { + const nonGlobalTypes = Object.values(specs) + .map((s) => s.chartType) + .filter((type) => type !== ChartTypes.Global) + .filter(keepDistinct); + if (nonGlobalTypes.length !== 1) { + Logger.warn(`${nonGlobalTypes.length === 0 ? 'Zero' : 'Multiple'} chart types in the same configuration`); return null; } - return chartTypes[0] as ChartTypes; + return nonGlobalTypes[0]; } -function initInternalChartState(chartType: ChartTypes | null): InternalChartState | null { +function newInternalState(chartType: ChartTypes | null): InternalChartState | null { switch (chartType) { case ChartTypes.Goal: return new GoalState(); @@ -473,7 +421,3 @@ function initInternalChartState(chartType: ChartTypes | null): InternalChartStat return null; } } - -function isChartTypeChanged(state: GlobalChartState, newChartType: ChartTypes | null) { - return state.chartType !== newChartType; -} diff --git a/src/utils/common.ts b/src/utils/common.ts index f45051fdad..0dda44d877 100644 --- a/src/utils/common.ts +++ b/src/utils/common.ts @@ -104,7 +104,7 @@ export type Datum = any; // unknown; export type Rotation = 0 | 90 | -90 | 180; /** @public */ export type Rendering = 'canvas' | 'svg'; -export type Color = string; +export type Color = string; // todo static/runtime type it this for proper color string content; several places in the code, and ultimate use, dictate it not be an empty string export type StrokeStyle = Color; // now narrower than string | CanvasGradient | CanvasPattern export const Position = Object.freeze({ @@ -539,3 +539,9 @@ export const getPercentageValue = (ratio: string | number, relativeValue: num return num && !isNaN(num) ? num : defaultValue; }; + +/** + * Predicate function, eg. to be called with [].filter, to keep distinct values + * @example [1, 2, 4, 2, 4, 0, 3, 2].filter(keepDistinct) ==> [1, 2, 4, 0, 3] + */ +export const keepDistinct = (d: T, i: number, a: T[]): boolean => a.indexOf(d) === i; diff --git a/stories/small_multiples/6_heterogeneous_cartesians.tsx b/stories/small_multiples/6_heterogeneous_cartesians.tsx new file mode 100644 index 0000000000..bc70c85827 --- /dev/null +++ b/stories/small_multiples/6_heterogeneous_cartesians.tsx @@ -0,0 +1,164 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { action } from '@storybook/addon-actions'; +import { boolean } from '@storybook/addon-knobs'; +import { DateTime } from 'luxon'; +import React from 'react'; + +import { + ScaleType, + Position, + Chart, + Axis, + GroupBy, + SmallMultiples, + Settings, + BarSeries, + LineAnnotation, + AnnotationDomainTypes, + LIGHT_THEME, + LineSeries, + AreaSeries, +} from '../../src'; +import { SeededDataGenerator } from '../../src/mocks/utils'; +import { SB_SOURCE_PANEL } from '../utils/storybook'; + +const dg = new SeededDataGenerator(); +const numOfDays = 7; +function generateData() { + return dg.generateGroupedSeries(numOfDays, 2).map((d) => { + return { + ...d, + x: DateTime.fromFormat(`${d.x + 1}`, 'E').toFormat('EEEE'), + y: Math.floor(d.y * 10), + g: d.g === 'a' ? 'new user' : 'existing user', + }; + }); +} +const data1 = generateData(); +const data2 = generateData(); +const data3 = generateData(); + +export const Example = () => { + const marker = ( + + MIN + + ); + const showLegend = boolean('Show Legend', false); + const onElementClick = action('onElementClick'); + + return ( + + + + + + { + return spec.id; + }} + sort="alphaAsc" + /> + + + + + + + ); +}; + +Example.story = { + parameters: { + options: { selectedPanel: SB_SOURCE_PANEL }, + info: { + text: `Similarly to the Vertical Areas example, the above chart shows an example of small multiples technique +that splits our dataset into multiple sub-series horizontally positioned one aside the other. +In this case, the \`\` id is used to specify the horizontal split via the \`splitHorizontally\` prop. + +As for single charts, we can merge and handle multiple data-series together and specify a \`by\` accessor to consider +the specific case. An additional property \`sort\` is available to configure the sorting order of the vertical or +horizontal split. +`, + }, + }, +}; diff --git a/stories/small_multiples/small_multiples.stories.tsx b/stories/small_multiples/small_multiples.stories.tsx index 40b3c66aff..d1d9bc9427 100644 --- a/stories/small_multiples/small_multiples.stories.tsx +++ b/stories/small_multiples/small_multiples.stories.tsx @@ -30,3 +30,4 @@ export { Example as verticalAreas } from './2_vertical_areas'; export { Example as horizontalBars } from './4_horizontal_bars'; export { Example as gridLines } from './3_grid_lines'; export { Example as histogramBars } from './5_histogram_bars'; +export { Example as heterogeneous } from './6_heterogeneous_cartesians'; diff --git a/stories/treemap/10_three_layers.tsx b/stories/treemap/10_three_layers.tsx index c4c7aa2b06..fdba45775f 100644 --- a/stories/treemap/10_three_layers.tsx +++ b/stories/treemap/10_three_layers.tsx @@ -26,6 +26,7 @@ import { ShapeTreeNode } from '../../src/chart_types/partition_chart/layout/type import { hueInterpolator } from '../../src/chart_types/partition_chart/layout/utils/calcs'; import { mocks } from '../../src/mocks/hierarchical'; import { palettes } from '../../src/mocks/hierarchical/palettes'; +import { keepDistinct } from '../../src/utils/common'; import { STORYBOOK_LIGHT_THEME } from '../shared'; import { countryLookup, productLookup, regionLookup } from '../utils/utils'; @@ -33,7 +34,7 @@ const interpolator = hueInterpolator(palettes.CET2s.map(([r, g, b]) => [r, g, b, const countries = mocks.sunburst .map((d: any) => d.dest) - .filter((d: any, i: number, a: any[]) => a.indexOf(d) === i) + .filter(keepDistinct) .sort() .reverse();