diff --git a/src/plugins/charts/common/constants.ts b/src/plugins/charts/common/constants.ts new file mode 100644 index 0000000000000..a36877408d46f --- /dev/null +++ b/src/plugins/charts/common/constants.ts @@ -0,0 +1,33 @@ +/* + * 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. + */ + +// Currently supported palettes. This list might be extended dynamically in a later release +export const paletteIds = [ + 'default', + 'kibana_palette', + 'custom', + 'status', + 'temperature', + 'complimentary', + 'negative', + 'positive', + 'cool', + 'warm', + 'gray', +]; diff --git a/src/plugins/charts/common/index.ts b/src/plugins/charts/common/index.ts index 1ebf3bcb8f4b6..2582851cb0bc7 100644 --- a/src/plugins/charts/common/index.ts +++ b/src/plugins/charts/common/index.ts @@ -18,3 +18,5 @@ */ export const COLOR_MAPPING_SETTING = 'visualization:colorMapping'; +export * from './palette'; +export * from './constants'; diff --git a/src/plugins/charts/common/palette.test.ts b/src/plugins/charts/common/palette.test.ts new file mode 100644 index 0000000000000..6081a396f8bf9 --- /dev/null +++ b/src/plugins/charts/common/palette.test.ts @@ -0,0 +1,102 @@ +/* + * 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 { + palette, + defaultCustomColors, + systemPalette, + PaletteOutput, + CustomPaletteState, +} from './palette'; +import { functionWrapper } from 'src/plugins/expressions/common/expression_functions/specs/tests/utils'; + +describe('palette', () => { + const fn = functionWrapper(palette()) as ( + context: null, + args?: { color?: string[]; gradient?: boolean; reverse?: boolean } + ) => PaletteOutput; + + it('results a palette', () => { + const result = fn(null); + expect(result).toHaveProperty('type', 'palette'); + }); + + describe('args', () => { + describe('color', () => { + it('sets colors', () => { + const result = fn(null, { color: ['red', 'green', 'blue'] }); + expect(result.params!.colors).toEqual(['red', 'green', 'blue']); + }); + + it('defaults to pault_tor_14 colors', () => { + const result = fn(null); + expect(result.params!.colors).toEqual(defaultCustomColors); + }); + }); + + describe('gradient', () => { + it('sets gradient', () => { + let result = fn(null, { gradient: true }); + expect(result.params).toHaveProperty('gradient', true); + + result = fn(null, { gradient: false }); + expect(result.params).toHaveProperty('gradient', false); + }); + + it('defaults to false', () => { + const result = fn(null); + expect(result.params).toHaveProperty('gradient', false); + }); + }); + + describe('reverse', () => { + it('reverses order of the colors', () => { + const result = fn(null, { reverse: true }); + expect(result.params!.colors).toEqual(defaultCustomColors.reverse()); + }); + + it('keeps the original order of the colors', () => { + const result = fn(null, { reverse: false }); + expect(result.params!.colors).toEqual(defaultCustomColors); + }); + + it(`defaults to 'false`, () => { + const result = fn(null); + expect(result.params!.colors).toEqual(defaultCustomColors); + }); + }); + }); +}); + +describe('system_palette', () => { + const fn = functionWrapper(systemPalette()) as ( + context: null, + args: { name: string; params?: unknown } + ) => PaletteOutput; + + it('results a palette', () => { + const result = fn(null, { name: 'test' }); + expect(result).toHaveProperty('type', 'palette'); + }); + + it('returns the name', () => { + const result = fn(null, { name: 'test' }); + expect(result).toHaveProperty('name', 'test'); + }); +}); diff --git a/src/plugins/charts/common/palette.ts b/src/plugins/charts/common/palette.ts new file mode 100644 index 0000000000000..1cf2af6946c7d --- /dev/null +++ b/src/plugins/charts/common/palette.ts @@ -0,0 +1,160 @@ +/* + * 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 { ExpressionFunctionDefinition } from 'src/plugins/expressions/common'; +import { i18n } from '@kbn/i18n'; +import { paletteIds } from './constants'; + +export interface CustomPaletteArguments { + color?: string[]; + gradient: boolean; + reverse?: boolean; +} + +export interface CustomPaletteState { + colors: string[]; + gradient: boolean; +} + +export interface SystemPaletteArguments { + name: string; +} + +export interface PaletteOutput { + type: 'palette'; + name: string; + params?: T; +} +export const defaultCustomColors = [ + // This set of defaults originated in Canvas, which, at present, is the primary + // consumer of this function. Changing this default requires a change in Canvas + // logic, which would likely be a breaking change in 7.x. + '#882E72', + '#B178A6', + '#D6C1DE', + '#1965B0', + '#5289C7', + '#7BAFDE', + '#4EB265', + '#90C987', + '#CAE0AB', + '#F7EE55', + '#F6C141', + '#F1932D', + '#E8601C', + '#DC050C', +]; + +export function palette(): ExpressionFunctionDefinition< + 'palette', + null, + CustomPaletteArguments, + PaletteOutput +> { + return { + name: 'palette', + aliases: [], + type: 'palette', + inputTypes: ['null'], + help: i18n.translate('charts.functions.paletteHelpText', { + defaultMessage: 'Creates a color palette.', + }), + args: { + color: { + aliases: ['_'], + multi: true, + types: ['string'], + help: i18n.translate('charts.functions.palette.args.colorHelpText', { + defaultMessage: + 'The palette colors. Accepts an {html} color name, {hex}, {hsl}, {hsla}, {rgb}, or {rgba}.', + values: { + html: 'HTML', + rgb: 'RGB', + rgba: 'RGBA', + hex: 'HEX', + hsl: 'HSL', + hsla: 'HSLA', + }, + }), + required: false, + }, + gradient: { + types: ['boolean'], + default: false, + help: i18n.translate('charts.functions.palette.args.gradientHelpText', { + defaultMessage: 'Make a gradient palette where supported?', + }), + options: [true, false], + }, + reverse: { + types: ['boolean'], + default: false, + help: i18n.translate('charts.functions.palette.args.reverseHelpText', { + defaultMessage: 'Reverse the palette?', + }), + options: [true, false], + }, + }, + fn: (input, args) => { + const { color, reverse, gradient } = args; + const colors = ([] as string[]).concat(color || defaultCustomColors); + + return { + type: 'palette', + name: 'custom', + params: { + colors: reverse ? colors.reverse() : colors, + gradient, + }, + }; + }, + }; +} + +export function systemPalette(): ExpressionFunctionDefinition< + 'system_palette', + null, + SystemPaletteArguments, + PaletteOutput +> { + return { + name: 'system_palette', + aliases: [], + type: 'palette', + inputTypes: ['null'], + help: i18n.translate('charts.functions.systemPaletteHelpText', { + defaultMessage: 'Creates a dynamic color palette.', + }), + args: { + name: { + types: ['string'], + help: i18n.translate('charts.functions.systemPalette.args.nameHelpText', { + defaultMessage: 'Name of the palette in the palette list', + }), + options: paletteIds, + }, + }, + fn: (input, args) => { + return { + type: 'palette', + name: args.name, + }; + }, + }; +} diff --git a/src/plugins/charts/kibana.json b/src/plugins/charts/kibana.json index 8967e931a0b10..a6d4dbba7238f 100644 --- a/src/plugins/charts/kibana.json +++ b/src/plugins/charts/kibana.json @@ -3,5 +3,6 @@ "version": "kibana", "server": true, "ui": true, + "requiredPlugins": ["expressions"], "requiredBundles": ["visDefaultEditor"] } diff --git a/src/plugins/charts/public/index.ts b/src/plugins/charts/public/index.ts index a8203a31a6847..8d7cf79363dae 100644 --- a/src/plugins/charts/public/index.ts +++ b/src/plugins/charts/public/index.ts @@ -21,7 +21,14 @@ import { ChartsPlugin } from './plugin'; export const plugin = () => new ChartsPlugin(); -export type ChartsPluginSetup = ReturnType; -export type ChartsPluginStart = ReturnType; +export { ChartsPluginSetup, ChartsPluginStart } from './plugin'; export * from './static'; +export * from './services/palettes/types'; +export { + PaletteOutput, + CustomPaletteArguments, + CustomPaletteState, + SystemPaletteArguments, + paletteIds, +} from '../common'; diff --git a/src/plugins/charts/public/mocks.ts b/src/plugins/charts/public/mocks.ts index d8fab7b535e9f..d082c23c28c07 100644 --- a/src/plugins/charts/public/mocks.ts +++ b/src/plugins/charts/public/mocks.ts @@ -19,19 +19,22 @@ import { ChartsPlugin } from './plugin'; import { themeServiceMock } from './services/theme/mock'; -import { colorsServiceMock } from './services/colors/mock'; +import { colorsServiceMock } from './services/legacy_colors/mock'; +import { getPaletteRegistry, paletteServiceMock } from './services/palettes/mock'; export type Setup = jest.Mocked>; export type Start = jest.Mocked>; const createSetupContract = (): Setup => ({ - colors: colorsServiceMock, + legacyColors: colorsServiceMock, theme: themeServiceMock, + palettes: paletteServiceMock.setup({} as any, {} as any), }); const createStartContract = (): Start => ({ - colors: colorsServiceMock, + legacyColors: colorsServiceMock, theme: themeServiceMock, + palettes: paletteServiceMock.setup({} as any, {} as any), }); export { colorMapsMock } from './static/color_maps/mock'; @@ -39,4 +42,5 @@ export { colorMapsMock } from './static/color_maps/mock'; export const chartPluginMock = { createSetupContract, createStartContract, + createPaletteRegistry: getPaletteRegistry, }; diff --git a/src/plugins/charts/public/plugin.ts b/src/plugins/charts/public/plugin.ts index bc91735f52052..5a28048ced430 100644 --- a/src/plugins/charts/public/plugin.ts +++ b/src/plugins/charts/public/plugin.ts @@ -18,16 +18,24 @@ */ import { Plugin, CoreSetup } from 'kibana/public'; +import { ExpressionsSetup } from '../../expressions/public'; +import { palette, systemPalette } from '../common'; -import { ThemeService, ColorsService } from './services'; +import { ThemeService, LegacyColorsService } from './services'; +import { PaletteService } from './services/palettes/service'; export type Theme = Omit; -export type Color = Omit; +export type Color = Omit; + +interface SetupDependencies { + expressions: ExpressionsSetup; +} /** @public */ export interface ChartsPluginSetup { - colors: Color; + legacyColors: Color; theme: Theme; + palettes: ReturnType; } /** @public */ @@ -36,22 +44,30 @@ export type ChartsPluginStart = ChartsPluginSetup; /** @public */ export class ChartsPlugin implements Plugin { private readonly themeService = new ThemeService(); - private readonly colorsService = new ColorsService(); + private readonly legacyColorsService = new LegacyColorsService(); + private readonly paletteService = new PaletteService(); + + private palettes: undefined | ReturnType; - public setup({ uiSettings }: CoreSetup): ChartsPluginSetup { - this.themeService.init(uiSettings); - this.colorsService.init(uiSettings); + public setup(core: CoreSetup, dependencies: SetupDependencies): ChartsPluginSetup { + dependencies.expressions.registerFunction(palette); + dependencies.expressions.registerFunction(systemPalette); + this.themeService.init(core.uiSettings); + this.legacyColorsService.init(core.uiSettings); + this.palettes = this.paletteService.setup(core, this.legacyColorsService); return { - colors: this.colorsService, + legacyColors: this.legacyColorsService, theme: this.themeService, + palettes: this.palettes, }; } public start(): ChartsPluginStart { return { - colors: this.colorsService, + legacyColors: this.legacyColorsService, theme: this.themeService, + palettes: this.palettes!, }; } } diff --git a/src/plugins/charts/public/services/index.ts b/src/plugins/charts/public/services/index.ts index 2bb4a99494e8a..f590ec9a5ebe6 100644 --- a/src/plugins/charts/public/services/index.ts +++ b/src/plugins/charts/public/services/index.ts @@ -17,5 +17,5 @@ * under the License. */ -export { ColorsService } from './colors'; +export { LegacyColorsService } from './legacy_colors'; export { ThemeService } from './theme'; diff --git a/src/plugins/charts/public/services/colors/colors.test.ts b/src/plugins/charts/public/services/legacy_colors/colors.test.ts similarity index 95% rename from src/plugins/charts/public/services/colors/colors.test.ts rename to src/plugins/charts/public/services/legacy_colors/colors.test.ts index a4d7a0781eabd..89cf7a4817377 100644 --- a/src/plugins/charts/public/services/colors/colors.test.ts +++ b/src/plugins/charts/public/services/legacy_colors/colors.test.ts @@ -19,14 +19,14 @@ import { coreMock } from '../../../../../core/public/mocks'; import { COLOR_MAPPING_SETTING } from '../../../common'; -import { seedColors } from './seed_colors'; -import { ColorsService } from './colors'; +import { seedColors } from '../../static/colors'; +import { LegacyColorsService } from './colors'; // Local state for config const config = new Map(); describe('Vislib Color Service', () => { - const colors = new ColorsService(); + const colors = new LegacyColorsService(); const mockUiSettings = coreMock.createSetup().uiSettings; mockUiSettings.get.mockImplementation((a) => config.get(a)); mockUiSettings.set.mockImplementation((...a) => config.set(...a) as any); @@ -55,7 +55,7 @@ describe('Vislib Color Service', () => { }); it('should throw error if not initialized', () => { - const colorsBad = new ColorsService(); + const colorsBad = new LegacyColorsService(); expect(() => colorsBad.createColorLookupFunction(arr, {})).toThrowError(); }); diff --git a/src/plugins/charts/public/services/colors/colors.ts b/src/plugins/charts/public/services/legacy_colors/colors.ts similarity index 94% rename from src/plugins/charts/public/services/colors/colors.ts rename to src/plugins/charts/public/services/legacy_colors/colors.ts index 7a1ffc433ee87..e1342a114f8df 100644 --- a/src/plugins/charts/public/services/colors/colors.ts +++ b/src/plugins/charts/public/services/legacy_colors/colors.ts @@ -21,8 +21,8 @@ import _ from 'lodash'; import { CoreSetup } from 'kibana/public'; -import { MappedColors } from './mapped_colors'; -import { seedColors } from './seed_colors'; +import { MappedColors } from '../mapped_colors'; +import { seedColors } from '../../static/colors'; /** * Accepts an array of strings or numbers that are used to create a @@ -30,7 +30,7 @@ import { seedColors } from './seed_colors'; * Returns a function that accepts a value (i.e. a string or number) * and returns a hex color associated with that value. */ -export class ColorsService { +export class LegacyColorsService { private _mappedColors?: MappedColors; public readonly seedColors = seedColors; diff --git a/src/plugins/charts/public/services/colors/colors_palette.test.ts b/src/plugins/charts/public/services/legacy_colors/colors_palette.test.ts similarity index 96% rename from src/plugins/charts/public/services/colors/colors_palette.test.ts rename to src/plugins/charts/public/services/legacy_colors/colors_palette.test.ts index 273a36f6a43a6..f77f6230e43e1 100644 --- a/src/plugins/charts/public/services/colors/colors_palette.test.ts +++ b/src/plugins/charts/public/services/legacy_colors/colors_palette.test.ts @@ -17,8 +17,8 @@ * under the License. */ -import { seedColors } from './seed_colors'; -import { createColorPalette } from './color_palette'; +import { seedColors } from '../../static/colors'; +import { createColorPalette } from '../../static/colors'; describe('Color Palette', () => { const num1 = 45; diff --git a/src/plugins/charts/public/services/legacy_colors/index.ts b/src/plugins/charts/public/services/legacy_colors/index.ts new file mode 100644 index 0000000000000..278d673f16f13 --- /dev/null +++ b/src/plugins/charts/public/services/legacy_colors/index.ts @@ -0,0 +1,20 @@ +/* + * 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. + */ + +export { LegacyColorsService } from './colors'; diff --git a/src/plugins/charts/public/services/colors/mock.ts b/src/plugins/charts/public/services/legacy_colors/mock.ts similarity index 82% rename from src/plugins/charts/public/services/colors/mock.ts rename to src/plugins/charts/public/services/legacy_colors/mock.ts index f88980e521dda..3c7ff4ebaa2f5 100644 --- a/src/plugins/charts/public/services/colors/mock.ts +++ b/src/plugins/charts/public/services/legacy_colors/mock.ts @@ -17,12 +17,16 @@ * under the License. */ -import { ColorsService } from './colors'; +import { LegacyColorsService } from './colors'; import { coreMock } from '../../../../../core/public/mocks'; -const colors = new ColorsService(); +const colors = new LegacyColorsService(); colors.init(coreMock.createSetup().uiSettings); -export const colorsServiceMock: ColorsService = { +export const colorsServiceMock: LegacyColorsService = { createColorLookupFunction: jest.fn(colors.createColorLookupFunction.bind(colors)), + mappedColors: { + mapKeys: jest.fn(), + get: jest.fn(), + }, } as any; diff --git a/src/plugins/charts/public/services/colors/index.ts b/src/plugins/charts/public/services/mapped_colors/index.ts similarity index 95% rename from src/plugins/charts/public/services/colors/index.ts rename to src/plugins/charts/public/services/mapped_colors/index.ts index 7ee5e0262e1b8..31509aef6c535 100644 --- a/src/plugins/charts/public/services/colors/index.ts +++ b/src/plugins/charts/public/services/mapped_colors/index.ts @@ -17,4 +17,4 @@ * under the License. */ -export { ColorsService } from './colors'; +export * from './mapped_colors'; diff --git a/src/plugins/charts/public/services/colors/mapped_colors.test.ts b/src/plugins/charts/public/services/mapped_colors/mapped_colors.test.ts similarity index 99% rename from src/plugins/charts/public/services/colors/mapped_colors.test.ts rename to src/plugins/charts/public/services/mapped_colors/mapped_colors.test.ts index 9d00bf098de4c..dc1f75ef7eb46 100644 --- a/src/plugins/charts/public/services/colors/mapped_colors.test.ts +++ b/src/plugins/charts/public/services/mapped_colors/mapped_colors.test.ts @@ -22,7 +22,7 @@ import Color from 'color'; import { coreMock } from '../../../../../core/public/mocks'; import { COLOR_MAPPING_SETTING } from '../../../common'; -import { seedColors } from './seed_colors'; +import { seedColors } from '../../static/colors'; import { MappedColors } from './mapped_colors'; // Local state for config diff --git a/src/plugins/charts/public/services/colors/mapped_colors.ts b/src/plugins/charts/public/services/mapped_colors/mapped_colors.ts similarity index 89% rename from src/plugins/charts/public/services/colors/mapped_colors.ts rename to src/plugins/charts/public/services/mapped_colors/mapped_colors.ts index 15f9be32b829c..2934d4208d22c 100644 --- a/src/plugins/charts/public/services/colors/mapped_colors.ts +++ b/src/plugins/charts/public/services/mapped_colors/mapped_colors.ts @@ -23,7 +23,7 @@ import Color from 'color'; import { CoreSetup } from 'kibana/public'; import { COLOR_MAPPING_SETTING } from '../../../common'; -import { createColorPalette } from './color_palette'; +import { createColorPalette } from '../../static/colors'; const standardizeColor = (color: string) => new Color(color).hex().toLowerCase(); @@ -36,7 +36,10 @@ export class MappedColors { private _oldMap: any; private _mapping: any; - constructor(private uiSettings: CoreSetup['uiSettings']) { + constructor( + private uiSettings: CoreSetup['uiSettings'], + private colorPaletteFn: (num: number) => string[] = createColorPalette + ) { this._oldMap = {}; this._mapping = {}; } @@ -57,6 +60,10 @@ export class MappedColors { return this.getConfigColorMapping()[key as any] || this._mapping[key]; } + getColorFromConfig(key: string | number) { + return this.getConfigColorMapping()[key as any]; + } + flush() { this._oldMap = _.clone(this._mapping); this._mapping = {}; @@ -89,7 +96,7 @@ export class MappedColors { // Generate a color palette big enough that all new keys can have unique color values const allColors = _(this._mapping).values().union(configColors).union(oldColors).value(); - const colorPalette = createColorPalette(allColors.length + keysToMap.length); + const colorPalette = this.colorPaletteFn(allColors.length + keysToMap.length); let newColors = _.difference(colorPalette, allColors); while (keysToMap.length > newColors.length) { diff --git a/src/plugins/charts/public/services/palettes/lighten_color.test.ts b/src/plugins/charts/public/services/palettes/lighten_color.test.ts new file mode 100644 index 0000000000000..643046ca444b6 --- /dev/null +++ b/src/plugins/charts/public/services/palettes/lighten_color.test.ts @@ -0,0 +1,44 @@ +/* + * 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 color from 'color'; +import { lightenColor } from './lighten_color'; + +describe('lighten_color', () => { + it('should keep existing color if there is a single color step', () => { + expect(lightenColor('#FF0000', 1, 1)).toEqual('#FF0000'); + }); + + it('should keep existing color for the first step', () => { + expect(lightenColor('#FF0000', 1, 10)).toEqual('#FF0000'); + }); + + it('should lighten color', () => { + const baseLightness = color('#FF0000', 'hsl').lightness(); + const result1 = lightenColor('#FF0000', 5, 10); + const result2 = lightenColor('#FF0000', 10, 10); + expect(baseLightness).toBeLessThan(color(result1, 'hsl').lightness()); + expect(color(result1, 'hsl').lightness()).toBeLessThan(color(result2, 'hsl').lightness()); + }); + + it('should not exceed top lightness', () => { + const result = lightenColor('#c0c0c0', 10, 10); + expect(color(result, 'hsl').lightness()).toBeLessThan(95); + }); +}); diff --git a/src/plugins/charts/public/services/palettes/lighten_color.ts b/src/plugins/charts/public/services/palettes/lighten_color.ts new file mode 100644 index 0000000000000..57ffb05eb5aa7 --- /dev/null +++ b/src/plugins/charts/public/services/palettes/lighten_color.ts @@ -0,0 +1,37 @@ +/* + * 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 color from 'color'; + +const MAX_LIGHTNESS = 93; +const MAX_LIGHTNESS_SPACE = 20; + +export function lightenColor(baseColor: string, step: number, totalSteps: number) { + if (totalSteps === 1) { + return baseColor; + } + + const hslColor = color(baseColor, 'hsl'); + const outputColorLightness = hslColor.lightness(); + const lightnessSpace = Math.min(MAX_LIGHTNESS - outputColorLightness, MAX_LIGHTNESS_SPACE); + const currentLevelTargetLightness = + outputColorLightness + lightnessSpace * ((step - 1) / (totalSteps - 1)); + const lightenedColor = hslColor.lightness(currentLevelTargetLightness); + return lightenedColor.hex(); +} diff --git a/src/plugins/charts/public/services/palettes/mock.ts b/src/plugins/charts/public/services/palettes/mock.ts new file mode 100644 index 0000000000000..a7ec3cc16ce6f --- /dev/null +++ b/src/plugins/charts/public/services/palettes/mock.ts @@ -0,0 +1,58 @@ +/* + * 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 { PublicMethodsOf } from '@kbn/utility-types'; +import { PaletteService } from './service'; +import { PaletteDefinition, SeriesLayer } from './types'; + +export const getPaletteRegistry = () => { + const mockPalette: jest.Mocked = { + id: 'default', + title: 'My Palette', + getColor: jest.fn((_: SeriesLayer[]) => 'black'), + getColors: jest.fn((num: number) => ['red', 'black']), + toExpression: jest.fn(() => ({ + type: 'expression', + chain: [ + { + type: 'function', + function: 'system_palette', + arguments: { + name: ['default'], + }, + }, + ], + })), + }; + + return { + get: (_: string) => mockPalette, + getAll: () => [mockPalette], + }; +}; + +export const paletteServiceMock: PublicMethodsOf = { + setup() { + return { + getPalettes: async () => { + return getPaletteRegistry(); + }, + }; + }, +}; diff --git a/src/plugins/charts/public/services/palettes/palettes.test.tsx b/src/plugins/charts/public/services/palettes/palettes.test.tsx new file mode 100644 index 0000000000000..5d9337f1ee683 --- /dev/null +++ b/src/plugins/charts/public/services/palettes/palettes.test.tsx @@ -0,0 +1,261 @@ +/* + * 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 { coreMock } from '../../../../../core/public/mocks'; +import { PaletteDefinition } from './types'; +import { buildPalettes } from './palettes'; +import { colorsServiceMock } from '../legacy_colors/mock'; + +describe('palettes', () => { + const palettes: Record = buildPalettes( + coreMock.createStart().uiSettings, + colorsServiceMock + ); + describe('default palette', () => { + it('should return different colors based on behind text flag', () => { + const palette = palettes.default; + + const color1 = palette.getColor([ + { + name: 'abc', + rankAtDepth: 0, + totalSeriesAtDepth: 5, + }, + ]); + const color2 = palette.getColor( + [ + { + name: 'abc', + rankAtDepth: 0, + totalSeriesAtDepth: 5, + }, + ], + { + behindText: true, + } + ); + expect(color1).not.toEqual(color2); + }); + + it('should return different colors based on rank at current series', () => { + const palette = palettes.default; + + const color1 = palette.getColor([ + { + name: 'abc', + rankAtDepth: 0, + totalSeriesAtDepth: 5, + }, + ]); + const color2 = palette.getColor([ + { + name: 'abc', + rankAtDepth: 1, + totalSeriesAtDepth: 5, + }, + ]); + expect(color1).not.toEqual(color2); + }); + + it('should return the same color for different positions on outer series layers', () => { + const palette = palettes.default; + + const color1 = palette.getColor([ + { + name: 'abc', + rankAtDepth: 0, + totalSeriesAtDepth: 5, + }, + { + name: 'def', + rankAtDepth: 0, + totalSeriesAtDepth: 2, + }, + ]); + const color2 = palette.getColor([ + { + name: 'abc', + rankAtDepth: 0, + totalSeriesAtDepth: 5, + }, + { + name: 'ghj', + rankAtDepth: 1, + totalSeriesAtDepth: 1, + }, + ]); + expect(color1).toEqual(color2); + }); + }); + + describe('gradient palette', () => { + const palette = palettes.warm; + + it('should use the whole gradient', () => { + const wholePalette = palette.getColors(10); + const color1 = palette.getColor([ + { + name: 'abc', + rankAtDepth: 0, + totalSeriesAtDepth: 10, + }, + ]); + const color2 = palette.getColor([ + { + name: 'def', + rankAtDepth: 9, + totalSeriesAtDepth: 10, + }, + ]); + expect(color1).toEqual(wholePalette[0]); + expect(color2).toEqual(wholePalette[9]); + }); + }); + + describe('legacy palette', () => { + const palette = palettes.kibana_palette; + + beforeEach(() => { + (colorsServiceMock.mappedColors.mapKeys as jest.Mock).mockClear(); + (colorsServiceMock.mappedColors.get as jest.Mock).mockClear(); + }); + + it('should query legacy color service', () => { + palette.getColor([ + { + name: 'abc', + rankAtDepth: 0, + totalSeriesAtDepth: 10, + }, + ]); + expect(colorsServiceMock.mappedColors.mapKeys).toHaveBeenCalledWith(['abc']); + expect(colorsServiceMock.mappedColors.get).toHaveBeenCalledWith('abc'); + }); + + it('should always use root series', () => { + palette.getColor([ + { + name: 'abc', + rankAtDepth: 0, + totalSeriesAtDepth: 10, + }, + { + name: 'def', + rankAtDepth: 0, + totalSeriesAtDepth: 10, + }, + ]); + expect(colorsServiceMock.mappedColors.mapKeys).toHaveBeenCalledTimes(1); + expect(colorsServiceMock.mappedColors.mapKeys).toHaveBeenCalledWith(['abc']); + expect(colorsServiceMock.mappedColors.get).toHaveBeenCalledTimes(1); + expect(colorsServiceMock.mappedColors.get).toHaveBeenCalledWith('abc'); + }); + }); + + describe('custom palette', () => { + const palette = palettes.custom; + it('should return different colors based on rank at current series', () => { + const color1 = palette.getColor( + [ + { + name: 'abc', + rankAtDepth: 0, + totalSeriesAtDepth: 5, + }, + ], + {}, + { + colors: ['#00ff00', '#000000'], + } + ); + const color2 = palette.getColor( + [ + { + name: 'abc', + rankAtDepth: 1, + totalSeriesAtDepth: 5, + }, + ], + {}, + { + colors: ['#00ff00', '#000000'], + } + ); + expect(color1).not.toEqual(color2); + }); + + it('should return the same color for different positions on outer series layers', () => { + const color1 = palette.getColor( + [ + { + name: 'abc', + rankAtDepth: 0, + totalSeriesAtDepth: 5, + }, + { + name: 'def', + rankAtDepth: 0, + totalSeriesAtDepth: 2, + }, + ], + {}, + { + colors: ['#00ff00', '#000000'], + } + ); + const color2 = palette.getColor( + [ + { + name: 'abc', + rankAtDepth: 0, + totalSeriesAtDepth: 5, + }, + { + name: 'ghj', + rankAtDepth: 1, + totalSeriesAtDepth: 1, + }, + ], + {}, + { + colors: ['#00ff00', '#000000'], + } + ); + expect(color1).toEqual(color2); + }); + + it('should use passed in colors', () => { + const color = palette.getColor( + [ + { + name: 'abc', + rankAtDepth: 0, + totalSeriesAtDepth: 10, + }, + ], + {}, + { + colors: ['#00ff00', '#000000'], + gradient: true, + } + ); + expect(color).toEqual('#00ff00'); + }); + }); +}); diff --git a/src/plugins/charts/public/services/palettes/palettes.tsx b/src/plugins/charts/public/services/palettes/palettes.tsx new file mode 100644 index 0000000000000..c1fd7c3cc739f --- /dev/null +++ b/src/plugins/charts/public/services/palettes/palettes.tsx @@ -0,0 +1,240 @@ +/* + * 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. + */ + +// @ts-ignore +import chroma from 'chroma-js'; +import { i18n } from '@kbn/i18n'; +import { IUiSettingsClient } from 'src/core/public'; +import { + euiPaletteColorBlind, + euiPaletteCool, + euiPaletteGray, + euiPaletteNegative, + euiPalettePositive, + euiPaletteWarm, + euiPaletteColorBlindBehindText, + euiPaletteForStatus, + euiPaletteForTemperature, + euiPaletteComplimentary, +} from '@elastic/eui'; +import { ChartsPluginSetup } from '../../../../../../src/plugins/charts/public'; +import { lightenColor } from './lighten_color'; +import { ChartColorConfiguration, PaletteDefinition, SeriesLayer } from './types'; +import { LegacyColorsService } from '../legacy_colors'; + +function buildRoundRobinCategoricalWithMappedColors(): Omit { + const colors = euiPaletteColorBlind({ rotations: 2 }); + const behindTextColors = euiPaletteColorBlindBehindText({ rotations: 2 }); + function getColor( + series: SeriesLayer[], + chartConfiguration: ChartColorConfiguration = { behindText: false } + ) { + const outputColor = chartConfiguration.behindText + ? behindTextColors[series[0].rankAtDepth % behindTextColors.length] + : colors[series[0].rankAtDepth % colors.length]; + + if (!chartConfiguration.maxDepth || chartConfiguration.maxDepth === 1) { + return outputColor; + } + + return lightenColor(outputColor, series.length, chartConfiguration.maxDepth); + } + return { + id: 'default', + getColor, + getColors: () => euiPaletteColorBlind(), + toExpression: () => ({ + type: 'expression', + chain: [ + { + type: 'function', + function: 'system_palette', + arguments: { + name: ['default'], + }, + }, + ], + }), + }; +} + +function buildGradient( + id: string, + colors: (n: number) => string[] +): Omit { + function getColor( + series: SeriesLayer[], + chartConfiguration: ChartColorConfiguration = { behindText: false } + ) { + const totalSeriesAtDepth = series[0].totalSeriesAtDepth; + const rankAtDepth = series[0].rankAtDepth; + const actualColors = colors(totalSeriesAtDepth); + const outputColor = actualColors[rankAtDepth]; + + if (!chartConfiguration.maxDepth || chartConfiguration.maxDepth === 1) { + return outputColor; + } + + return lightenColor(outputColor, series.length, chartConfiguration.maxDepth); + } + return { + id, + getColor, + getColors: colors, + toExpression: () => ({ + type: 'expression', + chain: [ + { + type: 'function', + function: 'system_palette', + arguments: { + name: [id], + }, + }, + ], + }), + }; +} + +function buildSyncedKibanaPalette( + colors: ChartsPluginSetup['legacyColors'] +): Omit { + function getColor(series: SeriesLayer[], chartConfiguration: ChartColorConfiguration = {}) { + colors.mappedColors.mapKeys([series[0].name]); + const outputColor = colors.mappedColors.get(series[0].name); + + if (!chartConfiguration.maxDepth || chartConfiguration.maxDepth === 1) { + return outputColor; + } + + return lightenColor(outputColor, series.length, chartConfiguration.maxDepth); + } + return { + id: 'kibana_palette', + getColor, + getColors: () => colors.seedColors.slice(0, 10), + toExpression: () => ({ + type: 'expression', + chain: [ + { + type: 'function', + function: 'system_palette', + arguments: { + name: ['kibana_palette'], + }, + }, + ], + }), + }; +} + +function buildCustomPalette(): PaletteDefinition { + return { + id: 'custom', + getColor: ( + series: SeriesLayer[], + chartConfiguration: ChartColorConfiguration = { behindText: false }, + { colors, gradient }: { colors: string[]; gradient: boolean } + ) => { + const actualColors = gradient + ? chroma.scale(colors).colors(series[0].totalSeriesAtDepth) + : colors; + const outputColor = actualColors[series[0].rankAtDepth % actualColors.length]; + + if (!chartConfiguration.maxDepth || chartConfiguration.maxDepth === 1) { + return outputColor; + } + + return lightenColor(outputColor, series.length, chartConfiguration.maxDepth); + }, + internal: true, + title: i18n.translate('charts.palettes.customLabel', { defaultMessage: 'Custom' }), + getColors: (size: number, { colors, gradient }: { colors: string[]; gradient: boolean }) => { + return gradient ? chroma.scale(colors).colors(size) : colors; + }, + toExpression: ({ colors, gradient }: { colors: string[]; gradient: boolean }) => ({ + type: 'expression', + chain: [ + { + type: 'function', + function: 'palette', + arguments: { + color: colors, + gradient: [gradient], + }, + }, + ], + }), + } as PaletteDefinition; +} + +export const buildPalettes: ( + uiSettings: IUiSettingsClient, + legacyColorsService: LegacyColorsService +) => Record = (uiSettings, legacyColorsService) => { + return { + default: { + title: i18n.translate('charts.palettes.defaultPaletteLabel', { + defaultMessage: 'Default', + }), + ...buildRoundRobinCategoricalWithMappedColors(), + }, + status: { + title: i18n.translate('charts.palettes.statusLabel', { defaultMessage: 'Status' }), + ...buildGradient('status', euiPaletteForStatus), + }, + temperature: { + title: i18n.translate('charts.palettes.temperatureLabel', { defaultMessage: 'Temperature' }), + ...buildGradient('temperature', euiPaletteForTemperature), + }, + complimentary: { + title: i18n.translate('charts.palettes.complimentaryLabel', { + defaultMessage: 'Complimentary', + }), + ...buildGradient('complimentary', euiPaletteComplimentary), + }, + negative: { + title: i18n.translate('charts.palettes.negativeLabel', { defaultMessage: 'Negative' }), + ...buildGradient('negative', euiPaletteNegative), + }, + positive: { + title: i18n.translate('charts.palettes.positiveLabel', { defaultMessage: 'Positive' }), + ...buildGradient('positive', euiPalettePositive), + }, + cool: { + title: i18n.translate('charts.palettes.coolLabel', { defaultMessage: 'Cool' }), + ...buildGradient('cool', euiPaletteCool), + }, + warm: { + title: i18n.translate('charts.palettes.warmLabel', { defaultMessage: 'Warm' }), + ...buildGradient('warm', euiPaletteWarm), + }, + gray: { + title: i18n.translate('charts.palettes.grayLabel', { defaultMessage: 'Gray' }), + ...buildGradient('gray', euiPaletteGray), + }, + kibana_palette: { + title: i18n.translate('charts.palettes.kibanaPaletteLabel', { + defaultMessage: 'Compatibility', + }), + ...buildSyncedKibanaPalette(legacyColorsService), + }, + custom: buildCustomPalette() as PaletteDefinition, + }; +}; diff --git a/src/plugins/charts/public/services/palettes/service.ts b/src/plugins/charts/public/services/palettes/service.ts new file mode 100644 index 0000000000000..5d0bc2c9037b2 --- /dev/null +++ b/src/plugins/charts/public/services/palettes/service.ts @@ -0,0 +1,56 @@ +/* + * 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 { CoreSetup } from 'kibana/public'; +import { ExpressionsSetup } from '../../../../../../src/plugins/expressions/public'; +import { + ChartsPluginSetup, + PaletteDefinition, + PaletteRegistry, +} from '../../../../../../src/plugins/charts/public'; +import { LegacyColorsService } from '../legacy_colors'; + +export interface PaletteSetupPlugins { + expressions: ExpressionsSetup; + charts: ChartsPluginSetup; +} + +export class PaletteService { + private palettes: Record> | undefined = undefined; + constructor() {} + + public setup(core: CoreSetup, colorsService: LegacyColorsService) { + return { + getPalettes: async (): Promise => { + if (!this.palettes) { + const { buildPalettes } = await import('./palettes'); + this.palettes = buildPalettes(core.uiSettings, colorsService); + } + return { + get: (name: string) => { + return this.palettes![name]; + }, + getAll: () => { + return Object.values(this.palettes!); + }, + }; + }, + }; + } +} diff --git a/src/plugins/charts/public/services/palettes/types.ts b/src/plugins/charts/public/services/palettes/types.ts new file mode 100644 index 0000000000000..f92bcb4bd0824 --- /dev/null +++ b/src/plugins/charts/public/services/palettes/types.ts @@ -0,0 +1,118 @@ +/* + * 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 { Ast } from '@kbn/interpreter/common'; + +/** + * Information about a series in a chart used to determine its color. + * Series layers can be nested, this means each series layer can have an ancestor. + */ +export interface SeriesLayer { + /** + * Name of the series (can be used for lookup-based coloring) + */ + name: string; + /** + * Rank of the series compared to siblings with the same ancestor + */ + rankAtDepth: number; + /** + * Total number of series with the same ancestor + */ + totalSeriesAtDepth: number; +} + +/** + * Information about the structure of a chart to determine the color of a series within it. + */ +export interface ChartColorConfiguration { + /** + * Overall number of series in the current chart + */ + totalSeries?: number; + /** + * Max nesting depth of the series tree + */ + maxDepth?: number; + /** + * Flag whether the color will be used behind text. The palette can use this information to + * adjust colors for better a11y. Might be ignored depending on the palette. + */ + behindText?: boolean; +} + +/** + * Definition of a global palette. + * + * A palette controls the appearance of Lens charts on an editor level. + * The palette wont get reset when switching charts. + * + * A palette can hold internal state (e.g. for customizations) and also includes + * an editor component to edit the internal state. + */ +export interface PaletteDefinition { + /** + * Unique id of the palette (this will be persisted along with the visualization state) + */ + id: string; + /** + * User facing title (should be i18n-ized) + */ + title: string; + /** + * Flag indicating whether users should be able to pick this palette manually. + */ + internal?: boolean; + /** + * Serialize the internal state of the palette into an expression function. + * This function should be used to pass the palette to the expression function applying color and other styles + * @param state The internal state of the palette + */ + toExpression: (state?: T) => Ast; + /** + * Renders the UI for editing the internal state of the palette. + * Not each palette has to feature an internal state, so this is an optional property. + * @param domElement The dom element to the render the editor UI into + * @param props Current state and state setter to issue updates + */ + renderEditor?: ( + domElement: Element, + props: { state?: T; setState: (updater: (oldState: T) => T) => void } + ) => void; + /** + * Color a series according to the internal rules of the palette. + * @param series The current series along with its ancestors. + * @param state The internal state of the palette + */ + getColor: ( + series: SeriesLayer[], + chartConfiguration?: ChartColorConfiguration, + state?: T + ) => string | null; + /** + * Get a spectrum of colors of the current palette. + * This can be used if the chart wants to control color assignment locally. + */ + getColors: (size: number, state?: T) => string[]; +} + +export interface PaletteRegistry { + get: (name: string) => PaletteDefinition; + getAll: () => Array>; +} diff --git a/src/plugins/charts/public/services/colors/color_palette.ts b/src/plugins/charts/public/static/colors/color_palette.ts similarity index 100% rename from src/plugins/charts/public/services/colors/color_palette.ts rename to src/plugins/charts/public/static/colors/color_palette.ts diff --git a/src/plugins/charts/public/static/colors/index.ts b/src/plugins/charts/public/static/colors/index.ts new file mode 100644 index 0000000000000..4970d2202b50e --- /dev/null +++ b/src/plugins/charts/public/static/colors/index.ts @@ -0,0 +1,21 @@ +/* + * 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. + */ + +export * from './color_palette'; +export * from './seed_colors'; diff --git a/src/plugins/charts/public/services/colors/seed_colors.test.ts b/src/plugins/charts/public/static/colors/seed_colors.test.ts similarity index 100% rename from src/plugins/charts/public/services/colors/seed_colors.test.ts rename to src/plugins/charts/public/static/colors/seed_colors.test.ts diff --git a/src/plugins/charts/public/services/colors/seed_colors.ts b/src/plugins/charts/public/static/colors/seed_colors.ts similarity index 100% rename from src/plugins/charts/public/services/colors/seed_colors.ts rename to src/plugins/charts/public/static/colors/seed_colors.ts diff --git a/src/plugins/charts/public/static/index.ts b/src/plugins/charts/public/static/index.ts index 6fc097d05467f..b8a8406c375dd 100644 --- a/src/plugins/charts/public/static/index.ts +++ b/src/plugins/charts/public/static/index.ts @@ -18,4 +18,5 @@ */ export * from './color_maps'; +export * from './colors'; export * from './components'; diff --git a/src/plugins/charts/server/index.ts b/src/plugins/charts/server/index.ts index 75a57ab6b405c..3e749489d42dd 100644 --- a/src/plugins/charts/server/index.ts +++ b/src/plugins/charts/server/index.ts @@ -18,5 +18,12 @@ */ import { ChartsServerPlugin } from './plugin'; +export { + PaletteOutput, + CustomPaletteArguments, + CustomPaletteState, + SystemPaletteArguments, + paletteIds, +} from '../common'; export const plugin = () => new ChartsServerPlugin(); diff --git a/src/plugins/charts/server/plugin.ts b/src/plugins/charts/server/plugin.ts index 6bf45fb804469..0123459bd25d2 100644 --- a/src/plugins/charts/server/plugin.ts +++ b/src/plugins/charts/server/plugin.ts @@ -20,10 +20,17 @@ import { i18n } from '@kbn/i18n'; import { schema } from '@kbn/config-schema'; import { CoreSetup, Plugin } from 'kibana/server'; -import { COLOR_MAPPING_SETTING } from '../common'; +import { COLOR_MAPPING_SETTING, palette, systemPalette } from '../common'; +import { ExpressionsServerSetup } from '../../expressions/server'; + +interface SetupDependencies { + expressions: ExpressionsServerSetup; +} export class ChartsServerPlugin implements Plugin { - public setup(core: CoreSetup) { + public setup(core: CoreSetup, dependencies: SetupDependencies) { + dependencies.expressions.registerFunction(palette); + dependencies.expressions.registerFunction(systemPalette); core.uiSettings.register({ [COLOR_MAPPING_SETTING]: { name: i18n.translate('charts.advancedSettings.visualization.colorMappingTitle', { diff --git a/src/plugins/vis_type_tagcloud/public/plugin.ts b/src/plugins/vis_type_tagcloud/public/plugin.ts index f1ea965e2c806..8fa22a88eb923 100644 --- a/src/plugins/vis_type_tagcloud/public/plugin.ts +++ b/src/plugins/vis_type_tagcloud/public/plugin.ts @@ -38,7 +38,7 @@ export interface TagCloudPluginSetupDependencies { /** @internal */ export interface TagCloudVisDependencies { - colors: ChartsPluginSetup['colors']; + colors: ChartsPluginSetup['legacyColors']; } /** @internal */ @@ -59,7 +59,7 @@ export class TagCloudPlugin implements Plugin { { expressions, visualizations, charts }: TagCloudPluginSetupDependencies ) { const visualizationDependencies: TagCloudVisDependencies = { - colors: charts.colors, + colors: charts.legacyColors, }; expressions.registerFunction(createTagCloudFn); expressions.registerRenderer(getTagCloudVisRenderer(visualizationDependencies)); diff --git a/src/plugins/vis_type_timeseries/public/application/visualizations/views/timeseries/index.js b/src/plugins/vis_type_timeseries/public/application/visualizations/views/timeseries/index.js index 278d7906dde94..36624cfeea0c2 100644 --- a/src/plugins/vis_type_timeseries/public/application/visualizations/views/timeseries/index.js +++ b/src/plugins/vis_type_timeseries/public/application/visualizations/views/timeseries/index.js @@ -97,7 +97,7 @@ export const TimeSeries = ({ // If the color isn't configured by the user, use the color mapping service // to assign a color from the Kibana palette. Colors will be shared across the // session, including dashboards. - const { colors, theme: themeService } = getChartsSetup(); + const { legacyColors: colors, theme: themeService } = getChartsSetup(); const baseTheme = getBaseTheme(themeService.useChartsBaseTheme(), backgroundColor); colors.mappedColors.mapKeys(series.filter(({ color }) => !color).map(({ label }) => label)); diff --git a/src/plugins/vis_type_vislib/public/vislib/vis.js b/src/plugins/vis_type_vislib/public/vislib/vis.js index 628b876fc50c5..3165ef10a80c8 100644 --- a/src/plugins/vis_type_vislib/public/vislib/vis.js +++ b/src/plugins/vis_type_vislib/public/vislib/vis.js @@ -57,7 +57,7 @@ export class Vis extends EventEmitter { this.data, this.uiState, this.element, - this.charts.colors.createColorLookupFunction.bind(this.charts.colors) + this.charts.legacyColors.createColorLookupFunction.bind(this.charts.legacyColors) ); } diff --git a/x-pack/plugins/canvas/__tests__/fixtures/function_specs.ts b/x-pack/plugins/canvas/__tests__/fixtures/function_specs.ts index 09b5e29cba87e..125dd20a66d8a 100644 --- a/x-pack/plugins/canvas/__tests__/fixtures/function_specs.ts +++ b/x-pack/plugins/canvas/__tests__/fixtures/function_specs.ts @@ -6,5 +6,8 @@ import { functions as browserFns } from '../../canvas_plugin_src/functions/browser'; import { ExpressionFunction } from '../../../../../src/plugins/expressions'; +import { initFunctions } from '../../public/functions'; -export const functionSpecs = browserFns.map((fn) => new ExpressionFunction(fn())); +export const functionSpecs = browserFns + .concat(...(initFunctions({} as any) as any)) + .map((fn) => new ExpressionFunction(fn())); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/fixtures/test_pointseries.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/fixtures/test_pointseries.js index 61dc84fa6bfb6..047cb386e0683 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/fixtures/test_pointseries.js +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/fixtures/test_pointseries.js @@ -6,6 +6,7 @@ export const testPlot = { type: 'pointseries', + palette: { type: 'palette', name: 'custom' }, columns: { x: { type: 'date', role: 'dimension', expression: 'time' }, y: { @@ -77,6 +78,7 @@ export const testPlot = { export const testPie = { type: 'pointseries', + palette: { type: 'palette', name: 'custom' }, columns: { color: { type: 'string', diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/fixtures/test_styles.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/fixtures/test_styles.js index 82d911e72772d..1848d796c61c5 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/fixtures/test_styles.js +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/fixtures/test_styles.js @@ -60,14 +60,20 @@ export const seriesStyle = { export const grayscalePalette = { type: 'palette', - colors: ['#FFFFFF', '#888888', '#000000'], - gradient: false, + name: 'custom', + params: { + colors: ['#FFFFFF', '#888888', '#000000'], + gradient: false, + }, }; export const gradientPalette = { type: 'palette', - colors: ['#FFFFFF', '#000000'], - gradient: true, + name: 'custom', + params: { + colors: ['#FFFFFF', '#000000'], + gradient: true, + }, }; export const xAxisConfig = { diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/index.ts b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/index.ts index 5ec831efbe35f..84a8c9278e8ab 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/index.ts +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/index.ts @@ -37,9 +37,6 @@ import { mapColumn } from './mapColumn'; import { math } from './math'; import { metric } from './metric'; import { neq } from './neq'; -import { palette } from './palette'; -import { pie } from './pie'; -import { plot } from './plot'; import { ply } from './ply'; import { progress } from './progress'; import { render } from './render'; @@ -95,9 +92,6 @@ export const functions = [ math, metric, neq, - palette, - pie, - plot, ply, progress, render, diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/palette.test.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/palette.test.js deleted file mode 100644 index 01cabd171c2fe..0000000000000 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/palette.test.js +++ /dev/null @@ -1,64 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { functionWrapper } from '../../../__tests__/helpers/function_wrapper'; -import { paulTor14 } from '../../../common/lib/palettes'; -import { palette } from './palette'; - -describe('palette', () => { - const fn = functionWrapper(palette); - - it('results a palette', () => { - const result = fn(null); - expect(result).toHaveProperty('type', 'palette'); - }); - - describe('args', () => { - describe('color', () => { - it('sets colors', () => { - const result = fn(null, { color: ['red', 'green', 'blue'] }); - expect(result.colors).toEqual(['red', 'green', 'blue']); - }); - - it('defaults to pault_tor_14 colors', () => { - const result = fn(null); - expect(result.colors).toEqual(paulTor14.colors); - }); - }); - - describe('gradient', () => { - it('sets gradient', () => { - let result = fn(null, { gradient: true }); - expect(result).toHaveProperty('gradient', true); - - result = fn(null, { gradient: false }); - expect(result).toHaveProperty('gradient', false); - }); - - it('defaults to false', () => { - const result = fn(null); - expect(result).toHaveProperty('gradient', false); - }); - }); - - describe('reverse', () => { - it('reverses order of the colors', () => { - const result = fn(null, { reverse: true }); - expect(result.colors).toEqual(paulTor14.colors.reverse()); - }); - - it('keeps the original order of the colors', () => { - const result = fn(null, { reverse: false }); - expect(result.colors).toEqual(paulTor14.colors); - }); - - it(`defaults to 'false`, () => { - const result = fn(null); - expect(result.colors).toEqual(paulTor14.colors); - }); - }); - }); -}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/palette.ts b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/palette.ts deleted file mode 100644 index 50d62a19b2361..0000000000000 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/palette.ts +++ /dev/null @@ -1,63 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { ExpressionFunctionDefinition } from 'src/plugins/expressions/common'; -import { paulTor14 } from '../../../common/lib/palettes'; -import { getFunctionHelp } from '../../../i18n'; - -interface Arguments { - color: string[]; - gradient: boolean; - reverse: boolean; -} - -interface Output { - type: 'palette'; - colors: string[]; - gradient: boolean; -} - -export function palette(): ExpressionFunctionDefinition<'palette', null, Arguments, Output> { - const { help, args: argHelp } = getFunctionHelp().palette; - - return { - name: 'palette', - aliases: [], - type: 'palette', - inputTypes: ['null'], - help, - args: { - color: { - aliases: ['_'], - multi: true, - types: ['string'], - help: argHelp.color, - }, - gradient: { - types: ['boolean'], - default: false, - help: argHelp.gradient, - options: [true, false], - }, - reverse: { - types: ['boolean'], - default: false, - help: argHelp.reverse, - options: [true, false], - }, - }, - fn: (input, args) => { - const { color, reverse, gradient } = args; - const colors = ([] as string[]).concat(color || paulTor14.colors); - - return { - type: 'palette', - colors: reverse ? colors.reverse() : colors, - gradient, - }; - }, - }; -} diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/pie.ts b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/pie.ts deleted file mode 100644 index 11551c50d9f25..0000000000000 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/pie.ts +++ /dev/null @@ -1,192 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { get, keyBy, map, groupBy } from 'lodash'; -// @ts-expect-error untyped local -import { getColorsFromPalette } from '../../../common/lib/get_colors_from_palette'; -// @ts-expect-error untyped local -import { getLegendConfig } from '../../../common/lib/get_legend_config'; -import { getFunctionHelp } from '../../../i18n'; -import { - Legend, - Palette, - PointSeries, - Render, - SeriesStyle, - Style, - ExpressionFunctionDefinition, -} from '../../../types'; - -interface PieSeriesOptions { - show: boolean; - innerRadius: number; - stroke: { - width: number; - }; - label: { - show: boolean; - radius: number; - }; - tilt: number; - radius: number | 'auto'; -} - -interface PieOptions { - canvas: boolean; - colors: string[]; - legend: { - show: boolean; - backgroundOpacity?: number; - labelBoxBorderColor?: string; - position?: Legend; - }; - grid: { - show: boolean; - }; - series: { - pie: PieSeriesOptions; - }; -} - -interface PieData { - label: string; - data: number[]; - color?: string; -} - -export interface Pie { - font: Style; - data: PieData[]; - options: PieOptions; -} - -interface Arguments { - palette: Palette; - seriesStyle: SeriesStyle[]; - radius: number | 'auto'; - hole: number; - labels: boolean; - labelRadius: number; - font: Style; - legend: Legend | false; - tilt: number; -} - -export function pie(): ExpressionFunctionDefinition<'pie', PointSeries, Arguments, Render> { - const { help, args: argHelp } = getFunctionHelp().pie; - - return { - name: 'pie', - aliases: [], - type: 'render', - inputTypes: ['pointseries'], - help, - args: { - font: { - types: ['style'], - help: argHelp.font, - default: '{font}', - }, - hole: { - types: ['number'], - default: 0, - help: argHelp.hole, - }, - labelRadius: { - types: ['number'], - default: 100, - help: argHelp.labelRadius, - }, - labels: { - types: ['boolean'], - default: true, - help: argHelp.labels, - }, - legend: { - types: ['string', 'boolean'], - help: argHelp.legend, - default: false, - options: [...Object.values(Legend), false], - }, - palette: { - types: ['palette'], - help: argHelp.palette, - default: '{palette}', - }, - radius: { - types: ['string', 'number'], - help: argHelp.radius, - default: 'auto', - }, - seriesStyle: { - multi: true, - types: ['seriesStyle'], - help: argHelp.seriesStyle, - }, - tilt: { - types: ['number'], - default: 1, - help: argHelp.tilt, - }, - }, - fn: (input, args) => { - const { tilt, radius, labelRadius, labels, hole, legend, palette, font, seriesStyle } = args; - const seriesStyles = keyBy(seriesStyle || [], 'label') || {}; - - const data: PieData[] = map(groupBy(input.rows, 'color'), (series, label = '') => { - const item: PieData = { - label, - data: series.map((point) => point.size || 1), - }; - - const style = seriesStyles[label]; - - // append series style, if there is a match - if (style) { - item.color = get(style, 'color'); - } - - return item; - }); - - return { - type: 'render', - as: 'pie', - value: { - font, - data, - options: { - canvas: false, - colors: getColorsFromPalette(palette, data.length), - legend: getLegendConfig(legend, data.length), - grid: { - show: false, - }, - series: { - pie: { - show: true, - innerRadius: Math.max(hole, 0) / 100, - stroke: { - width: 0, - }, - label: { - show: labels, - radius: (labelRadius >= 0 ? labelRadius : 100) / 100, - }, - tilt, - radius, - }, - bubbles: { - show: false, - }, - shadowSize: 0, - }, - }, - }, - }; - }, - }; -} diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/plot/index.ts b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/plot/index.ts deleted file mode 100644 index 9dc7ee8da6d73..0000000000000 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/plot/index.ts +++ /dev/null @@ -1,172 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { set } from '@elastic/safer-lodash-set'; -import { groupBy, get, keyBy, map, sortBy } from 'lodash'; -import { ExpressionFunctionDefinition, Style } from 'src/plugins/expressions'; -// @ts-expect-error untyped local -import { getColorsFromPalette } from '../../../../common/lib/get_colors_from_palette'; -// @ts-expect-error untyped local -import { getLegendConfig } from '../../../../common/lib/get_legend_config'; -import { getFlotAxisConfig } from './get_flot_axis_config'; -import { getFontSpec } from './get_font_spec'; -import { seriesStyleToFlot } from './series_style_to_flot'; -import { getTickHash } from './get_tick_hash'; -import { getFunctionHelp } from '../../../../i18n'; -import { AxisConfig, PointSeries, Render, SeriesStyle, Palette, Legend } from '../../../../types'; - -interface Arguments { - seriesStyle: SeriesStyle[]; - defaultStyle: SeriesStyle; - palette: Palette; - font: Style; - legend: Legend | boolean; - xaxis: AxisConfig | boolean; - yaxis: AxisConfig | boolean; -} - -export function plot(): ExpressionFunctionDefinition<'plot', PointSeries, Arguments, Render> { - const { help, args: argHelp } = getFunctionHelp().plot; - - return { - name: 'plot', - aliases: [], - type: 'render', - inputTypes: ['pointseries'], - help, - args: { - defaultStyle: { - multi: false, - types: ['seriesStyle'], - help: argHelp.defaultStyle, - default: '{seriesStyle points=5}', - }, - font: { - types: ['style'], - help: argHelp.font, - default: '{font}', - }, - legend: { - types: ['string', 'boolean'], - help: argHelp.legend, - default: 'ne', - options: [...Object.values(Legend), false], - }, - palette: { - types: ['palette'], - help: argHelp.palette, - default: '{palette}', - }, - seriesStyle: { - multi: true, - types: ['seriesStyle'], - help: argHelp.seriesStyle, - }, - xaxis: { - types: ['boolean', 'axisConfig'], - help: argHelp.xaxis, - default: true, - }, - yaxis: { - types: ['boolean', 'axisConfig'], - help: argHelp.yaxis, - default: true, - }, - }, - fn: (input, args) => { - const seriesStyles: { [key: string]: SeriesStyle } = - keyBy(args.seriesStyle || [], 'label') || {}; - - const sortedRows = sortBy(input.rows, ['x', 'y', 'color', 'size', 'text']); - const ticks = getTickHash(input.columns, sortedRows); - const font = args.font ? getFontSpec(args.font) : {}; - - const data = map(groupBy(sortedRows, 'color'), (series, label) => { - const seriesStyle = { - ...args.defaultStyle, - ...seriesStyles[label as string], - }; - - const flotStyle = seriesStyle ? seriesStyleToFlot(seriesStyle) : {}; - - return { - ...flotStyle, - label, - data: series.map((point) => { - const attrs: { - size?: number; - text?: string; - } = {}; - - const x = get(input.columns, 'x.type') === 'string' ? ticks.x.hash[point.x] : point.x; - const y = get(input.columns, 'y.type') === 'string' ? ticks.y.hash[point.y] : point.y; - - if (point.size != null) { - attrs.size = point.size; - } else if (get(seriesStyle, 'points')) { - attrs.size = seriesStyle.points; - set(flotStyle, 'bubbles.size.min', seriesStyle.points); - } - - if (point.text != null) { - attrs.text = point.text; - } - - return [x, y, attrs]; - }), - }; - }); - - const gridConfig = { - borderWidth: 0, - borderColor: null, - color: 'rgba(0,0,0,0)', - labelMargin: 30, - margin: { - right: 30, - top: 20, - bottom: 0, - left: 0, - }, - }; - - const output = { - type: 'render', - as: 'plot', - value: { - font: args.font, - data: sortBy(data, 'label'), - options: { - canvas: false, - colors: getColorsFromPalette(args.palette, data.length), - legend: getLegendConfig(args.legend, data.length), - grid: gridConfig, - xaxis: getFlotAxisConfig('x', args.xaxis, { - columns: input.columns, - ticks, - font, - }), - yaxis: getFlotAxisConfig('y', args.yaxis, { - columns: input.columns, - ticks, - font, - }), - series: { - shadowSize: 0, - ...seriesStyleToFlot(args.defaultStyle), - }, - }, - }, - }; - - // fix the issue of plot sometimes re-rendering with an empty chart - // TODO: holy hell, why does this work?! the working theory is that some values become undefined - // and serializing the result here causes them to be dropped off, and this makes flot react differently. - // It's also possible that something else ends up mutating this object, but that seems less likely. - return JSON.parse(JSON.stringify(output)); - }, - }; -} diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/external/saved_lens.ts b/x-pack/plugins/canvas/canvas_plugin_src/functions/external/saved_lens.ts index a823d0606d46f..765ff50728228 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/external/saved_lens.ts +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/external/saved_lens.ts @@ -5,6 +5,7 @@ */ import { ExpressionFunctionDefinition } from 'src/plugins/expressions/common'; +import { PaletteOutput } from 'src/plugins/charts/common'; import { TimeRange, Filter as DataFilter } from 'src/plugins/data/public'; import { EmbeddableInput } from 'src/plugins/embeddable/public'; import { getQueryFilters } from '../../../public/lib/build_embeddable_filters'; @@ -20,12 +21,14 @@ interface Arguments { id: string; title: string | null; timerange: TimeRangeArg | null; + palette?: PaletteOutput; } export type SavedLensInput = EmbeddableInput & { id: string; timeRange?: TimeRange; filters: DataFilter[]; + palette?: PaletteOutput; }; const defaultTimeRange = { @@ -61,6 +64,11 @@ export function savedLens(): ExpressionFunctionDefinition< help: argHelp.title, required: false, }, + palette: { + types: ['palette'], + help: argHelp.palette!, + required: false, + }, }, type: EmbeddableExpressionType, fn: (input, args) => { @@ -74,6 +82,7 @@ export function savedLens(): ExpressionFunctionDefinition< timeRange: args.timerange || defaultTimeRange, title: args.title === null ? undefined : args.title, disableTriggers: true, + palette: args.palette, }, embeddableType: EmbeddableTypes.lens, generatedAt: Date.now(), diff --git a/x-pack/plugins/canvas/canvas_plugin_src/plugin.ts b/x-pack/plugins/canvas/canvas_plugin_src/plugin.ts index 55f5319bbadb7..7ecebd6d0677a 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/plugin.ts +++ b/x-pack/plugins/canvas/canvas_plugin_src/plugin.ts @@ -5,6 +5,7 @@ */ import { CoreSetup, CoreStart, Plugin } from 'src/core/public'; +import { ChartsPluginStart } from 'src/plugins/charts/public'; import { CanvasSetup } from '../public'; import { EmbeddableStart } from '../../../../src/plugins/embeddable/public'; import { UiActionsStart } from '../../../../src/plugins/ui_actions/public'; @@ -32,6 +33,7 @@ export interface StartDeps { embeddable: EmbeddableStart; uiActions: UiActionsStart; inspector: InspectorStart; + charts: ChartsPluginStart; } export type SetupInitializer = (core: CoreSetup, plugins: SetupDeps) => T; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable.tsx b/x-pack/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable.tsx index 641580d9c58a5..bcc1f5ae5e844 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable.tsx +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable.tsx @@ -68,11 +68,17 @@ export const embeddableRendererFactory = ( const embeddableObject = await factory.createFromSavedObject(input.id, input); + const palettes = await plugins.charts.palettes.getPalettes(); + embeddablesRegistry[uniqueId] = embeddableObject; ReactDOM.unmountComponentAtNode(domNode); const subscription = embeddableObject.getInput$().subscribe(function (updatedInput) { - const updatedExpression = embeddableInputToExpression(updatedInput, embeddableType); + const updatedExpression = embeddableInputToExpression( + updatedInput, + embeddableType, + palettes + ); if (updatedExpression) { handlers.onEmbeddableInputChange(updatedExpression); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable_input_to_expression.test.ts b/x-pack/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable_input_to_expression.test.ts index 9dee40c0f683b..abdbdc1a6301a 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable_input_to_expression.test.ts +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable_input_to_expression.test.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import { chartPluginMock } from 'src/plugins/charts/public/mocks'; import { embeddableInputToExpression, inputToExpressionTypeMap, @@ -21,7 +22,11 @@ describe('input to expression', () => { const mockReturn = 'expression'; inputToExpressionTypeMap[newType] = jest.fn().mockReturnValue(mockReturn); - const expression = embeddableInputToExpression(input, newType); + const expression = embeddableInputToExpression( + input, + newType, + chartPluginMock.createPaletteRegistry() + ); expect(expression).toBe(mockReturn); }); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable_input_to_expression.ts b/x-pack/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable_input_to_expression.ts index 5cba012fcb8e3..d4f6a46d5931a 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable_input_to_expression.ts +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable_input_to_expression.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import { PaletteRegistry } from 'src/plugins/charts/public'; import { EmbeddableTypes, EmbeddableInput } from '../../expression_types'; import { toExpression as mapToExpression } from './input_type_to_expression/map'; import { toExpression as visualizationToExpression } from './input_type_to_expression/visualization'; @@ -20,9 +21,10 @@ export const inputToExpressionTypeMap = { */ export function embeddableInputToExpression( input: EmbeddableInput, - embeddableType: string + embeddableType: string, + palettes: PaletteRegistry ): string | undefined { if (inputToExpressionTypeMap[embeddableType]) { - return inputToExpressionTypeMap[embeddableType](input as any); + return inputToExpressionTypeMap[embeddableType](input as any, palettes); } } diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/embeddable/input_type_to_expression/lens.test.ts b/x-pack/plugins/canvas/canvas_plugin_src/renderers/embeddable/input_type_to_expression/lens.test.ts index 0df39f281da9c..60eab94f82bfa 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/renderers/embeddable/input_type_to_expression/lens.test.ts +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/embeddable/input_type_to_expression/lens.test.ts @@ -7,6 +7,7 @@ import { toExpression } from './lens'; import { SavedLensInput } from '../../../functions/external/saved_lens'; import { fromExpression, Ast } from '@kbn/interpreter/common'; +import { chartPluginMock } from 'src/plugins/charts/public/mocks'; const baseEmbeddableInput = { id: 'embeddableId', @@ -19,7 +20,7 @@ describe('toExpression', () => { ...baseEmbeddableInput, }; - const expression = toExpression(input); + const expression = toExpression(input, chartPluginMock.createPaletteRegistry()); const ast = fromExpression(expression); expect(ast.type).toBe('expression'); @@ -41,7 +42,7 @@ describe('toExpression', () => { }, }; - const expression = toExpression(input); + const expression = toExpression(input, chartPluginMock.createPaletteRegistry()); const ast = fromExpression(expression); expect(ast.chain[0].arguments).toHaveProperty('title', [input.title]); @@ -59,7 +60,7 @@ describe('toExpression', () => { title: '', }; - const expression = toExpression(input); + const expression = toExpression(input, chartPluginMock.createPaletteRegistry()); const ast = fromExpression(expression); expect(ast.chain[0].arguments).toHaveProperty('title', [input.title]); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/embeddable/input_type_to_expression/lens.ts b/x-pack/plugins/canvas/canvas_plugin_src/renderers/embeddable/input_type_to_expression/lens.ts index a8e200dd3e4ba..4d3676d0764fc 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/renderers/embeddable/input_type_to_expression/lens.ts +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/embeddable/input_type_to_expression/lens.ts @@ -4,9 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ +import { toExpression as toExpressionString } from '@kbn/interpreter/common'; +import { PaletteRegistry } from 'src/plugins/charts/public'; import { SavedLensInput } from '../../../functions/external/saved_lens'; -export function toExpression(input: SavedLensInput): string { +export function toExpression(input: SavedLensInput, palettes: PaletteRegistry): string { const expressionParts = [] as string[]; expressionParts.push('savedLens'); @@ -23,5 +25,13 @@ export function toExpression(input: SavedLensInput): string { ); } + if (input.palette) { + expressionParts.push( + `palette={${toExpressionString( + palettes.get(input.palette.name).toExpression(input.palette.params) + )}}` + ); + } + return expressionParts.join(' '); } diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/pie/index.tsx b/x-pack/plugins/canvas/canvas_plugin_src/renderers/pie/index.tsx index e8bffc873307b..9b8788e1942bf 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/renderers/pie/index.tsx +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/pie/index.tsx @@ -9,7 +9,7 @@ import 'jquery'; import { debounce } from 'lodash'; import { RendererStrings } from '../../../i18n'; -import { Pie } from '../../functions/common/pie'; +import { Pie } from '../../../public/functions/pie'; import { RendererFactory } from '../../../types'; const { pie: strings } = RendererStrings; diff --git a/x-pack/plugins/canvas/common/lib/get_colors_from_palette.js b/x-pack/plugins/canvas/common/lib/get_colors_from_palette.js deleted file mode 100644 index 9b18c1eb95197..0000000000000 --- a/x-pack/plugins/canvas/common/lib/get_colors_from_palette.js +++ /dev/null @@ -1,10 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import chroma from 'chroma-js'; - -export const getColorsFromPalette = (palette, size) => - palette.gradient ? chroma.scale(palette.colors).colors(size) : palette.colors; diff --git a/x-pack/plugins/canvas/common/lib/get_colors_from_palette.test.js b/x-pack/plugins/canvas/common/lib/get_colors_from_palette.test.js deleted file mode 100644 index ebc72db1f67f0..0000000000000 --- a/x-pack/plugins/canvas/common/lib/get_colors_from_palette.test.js +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { - grayscalePalette, - gradientPalette, -} from '../../canvas_plugin_src/functions/common/__tests__/fixtures/test_styles'; -import { getColorsFromPalette } from './get_colors_from_palette'; - -describe('getColorsFromPalette', () => { - it('returns the array of colors from a palette object when gradient is false', () => { - expect(getColorsFromPalette(grayscalePalette, 20)).toBe(grayscalePalette.colors); - }); - - it('returns an array of colors with equidistant colors with length equal to the number of series when gradient is true', () => { - const result = getColorsFromPalette(gradientPalette, 16); - expect(result).toEqual([ - '#ffffff', - '#eeeeee', - '#dddddd', - '#cccccc', - '#bbbbbb', - '#aaaaaa', - '#999999', - '#888888', - '#777777', - '#666666', - '#555555', - '#444444', - '#333333', - '#222222', - '#111111', - '#000000', - ]); - expect(result).toHaveLength(16); - }); -}); diff --git a/x-pack/plugins/canvas/common/lib/index.ts b/x-pack/plugins/canvas/common/lib/index.ts index c8ae53917c9e4..1a38e606844ec 100644 --- a/x-pack/plugins/canvas/common/lib/index.ts +++ b/x-pack/plugins/canvas/common/lib/index.ts @@ -15,8 +15,6 @@ export * from './errors'; export * from './expression_form_handlers'; export * from './fetch'; export * from './fonts'; -// @ts-expect-error missing local definition -export * from './get_colors_from_palette'; export * from './get_field_type'; // @ts-expect-error missing local definition export * from './get_legend_config'; diff --git a/x-pack/plugins/canvas/i18n/functions/dict/palette.ts b/x-pack/plugins/canvas/i18n/functions/dict/palette.ts deleted file mode 100644 index ff252ec9c0ac8..0000000000000 --- a/x-pack/plugins/canvas/i18n/functions/dict/palette.ts +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { i18n } from '@kbn/i18n'; -import { palette } from '../../../canvas_plugin_src/functions/common/palette'; -import { FunctionHelp } from '../function_help'; -import { FunctionFactory } from '../../../types'; - -export const help: FunctionHelp> = { - help: i18n.translate('xpack.canvas.functions.paletteHelpText', { - defaultMessage: 'Creates a color palette.', - }), - args: { - color: i18n.translate('xpack.canvas.functions.palette.args.colorHelpText', { - defaultMessage: - 'The palette colors. Accepts an {html} color name, {hex}, {hsl}, {hsla}, {rgb}, or {rgba}.', - values: { - html: 'HTML', - rgb: 'RGB', - rgba: 'RGBA', - hex: 'HEX', - hsl: 'HSL', - hsla: 'HSLA', - }, - }), - gradient: i18n.translate('xpack.canvas.functions.palette.args.gradientHelpText', { - defaultMessage: 'Make a gradient palette where supported?', - }), - reverse: i18n.translate('xpack.canvas.functions.palette.args.reverseHelpText', { - defaultMessage: 'Reverse the palette?', - }), - }, -}; diff --git a/x-pack/plugins/canvas/i18n/functions/dict/pie.ts b/x-pack/plugins/canvas/i18n/functions/dict/pie.ts index 149c2f8f1e634..a2c757d61bb27 100644 --- a/x-pack/plugins/canvas/i18n/functions/dict/pie.ts +++ b/x-pack/plugins/canvas/i18n/functions/dict/pie.ts @@ -5,13 +5,12 @@ */ import { i18n } from '@kbn/i18n'; -import { pie } from '../../../canvas_plugin_src/functions/common/pie'; -import { FunctionHelp } from '../function_help'; -import { FunctionFactory } from '../../../types'; +import { pieFunctionFactory } from '../../../public/functions/pie'; +import { FunctionFactoryHelp } from '../function_help'; import { Legend } from '../../../types'; import { CSS, FONT_FAMILY, FONT_WEIGHT, BOOLEAN_FALSE } from '../../constants'; -export const help: FunctionHelp> = { +export const help: FunctionFactoryHelp = { help: i18n.translate('xpack.canvas.functions.pieHelpText', { defaultMessage: 'Configures a pie chart element.', }), diff --git a/x-pack/plugins/canvas/i18n/functions/dict/plot.ts b/x-pack/plugins/canvas/i18n/functions/dict/plot.ts index aca2476a6592e..aee7fc5e1a3bd 100644 --- a/x-pack/plugins/canvas/i18n/functions/dict/plot.ts +++ b/x-pack/plugins/canvas/i18n/functions/dict/plot.ts @@ -5,13 +5,12 @@ */ import { i18n } from '@kbn/i18n'; -import { plot } from '../../../canvas_plugin_src/functions/common/plot'; -import { FunctionHelp } from '../function_help'; -import { FunctionFactory } from '../../../types'; +import { plotFunctionFactory } from '../../../public/functions/plot'; +import { FunctionFactoryHelp } from '../function_help'; import { Legend } from '../../../types'; import { CSS, FONT_FAMILY, FONT_WEIGHT, BOOLEAN_FALSE } from '../../constants'; -export const help: FunctionHelp> = { +export const help: FunctionFactoryHelp = { help: i18n.translate('xpack.canvas.functions.plotHelpText', { defaultMessage: 'Configures a chart element.', }), diff --git a/x-pack/plugins/canvas/i18n/functions/dict/saved_lens.ts b/x-pack/plugins/canvas/i18n/functions/dict/saved_lens.ts index 1121aa43f3509..d14aff16a864c 100644 --- a/x-pack/plugins/canvas/i18n/functions/dict/saved_lens.ts +++ b/x-pack/plugins/canvas/i18n/functions/dict/saved_lens.ts @@ -23,5 +23,8 @@ export const help: FunctionHelp> = { title: i18n.translate('xpack.canvas.functions.savedLens.args.titleHelpText', { defaultMessage: `The title for the Lens visualization object`, }), + palette: i18n.translate('xpack.canvas.functions.savedLens.args.paletteHelpText', { + defaultMessage: `The palette used for the Lens visualization`, + }), }, }; diff --git a/x-pack/plugins/canvas/i18n/functions/function_help.ts b/x-pack/plugins/canvas/i18n/functions/function_help.ts index e7d7b4ca4321b..50b6a9db9ae16 100644 --- a/x-pack/plugins/canvas/i18n/functions/function_help.ts +++ b/x-pack/plugins/canvas/i18n/functions/function_help.ts @@ -6,7 +6,7 @@ import { ExpressionFunctionDefinition } from 'src/plugins/expressions'; import { UnionToIntersection } from '@kbn/utility-types'; -import { CanvasFunction } from '../../types'; +import { CanvasFunction, FunctionFactory } from '../../types'; import { help as all } from './dict/all'; import { help as alterColumn } from './dict/alter_column'; @@ -50,7 +50,6 @@ import { help as markdown } from './dict/markdown'; import { help as math } from './dict/math'; import { help as metric } from './dict/metric'; import { help as neq } from './dict/neq'; -import { help as palette } from './dict/palette'; import { help as pie } from './dict/pie'; import { help as plot } from './dict/plot'; import { help as ply } from './dict/ply'; @@ -122,6 +121,15 @@ export type FunctionHelp = T extends ExpressionFunctionDefinition< } : never; +/** + * Helper type to use `FunctionHelp` for function definitions wrapped into factory functions. + * It creates a strongly typed entry for the `FunctionHelpMap` for the function definition generated + * by the passed in factory: `type MyFnHelp = FunctionFactoryHelp` + */ +export type FunctionFactoryHelp any> = FunctionHelp< + FunctionFactory> +>; + // This internal type infers a Function name and uses `FunctionHelp` above to build // a dictionary entry. This can be used to ensure every Function is defined and all // Arguments have help strings. @@ -205,7 +213,6 @@ export const getFunctionHelp = (): FunctionHelpDict => ({ math, metric, neq, - palette, pie, plot, ply, diff --git a/x-pack/plugins/canvas/kibana.json b/x-pack/plugins/canvas/kibana.json index dbd93de6b50e0..38bbb074f6dbd 100644 --- a/x-pack/plugins/canvas/kibana.json +++ b/x-pack/plugins/canvas/kibana.json @@ -5,7 +5,7 @@ "configPath": ["xpack", "canvas"], "server": true, "ui": true, - "requiredPlugins": ["bfetch", "data", "embeddable", "expressions", "features", "inspector", "uiActions"], + "requiredPlugins": ["bfetch", "data", "embeddable", "expressions", "features", "inspector", "uiActions", "charts"], "optionalPlugins": ["usageCollection", "home"], "requiredBundles": ["kibanaReact", "maps", "lens", "visualizations", "kibanaUtils", "kibanaLegacy", "discover", "savedObjects", "reporting", "home"] } diff --git a/x-pack/plugins/canvas/public/application.tsx b/x-pack/plugins/canvas/public/application.tsx index 463fb1efbd3b5..9fcd6ccc8ae88 100644 --- a/x-pack/plugins/canvas/public/application.tsx +++ b/x-pack/plugins/canvas/public/application.tsx @@ -86,6 +86,7 @@ export const initializeCanvas = async ( timefilter: setupPlugins.data.query.timefilter.timefilter, prependBasePath: coreSetup.http.basePath.prepend, types: setupPlugins.expressions.getTypes(), + paletteService: await setupPlugins.charts.palettes.getPalettes(), }); for (const fn of canvasFunctions) { diff --git a/x-pack/plugins/canvas/public/functions/index.ts b/x-pack/plugins/canvas/public/functions/index.ts index a7893162be8f8..5675467eb712d 100644 --- a/x-pack/plugins/canvas/public/functions/index.ts +++ b/x-pack/plugins/canvas/public/functions/index.ts @@ -4,14 +4,18 @@ * you may not use this file except in compliance with the Elastic License. */ +import { PaletteRegistry } from 'src/plugins/charts/public'; import { asset } from './asset'; import { filtersFunctionFactory } from './filters'; import { timelionFunctionFactory } from './timelion'; import { toFunctionFactory } from './to'; import { CanvasSetupDeps, CoreSetup } from '../plugin'; +import { plotFunctionFactory } from './plot'; +import { pieFunctionFactory } from './pie'; export interface InitializeArguments { prependBasePath: CoreSetup['http']['basePath']['prepend']; + paletteService: PaletteRegistry; types: ReturnType; timefilter: CanvasSetupDeps['data']['query']['timefilter']['timefilter']; } @@ -22,5 +26,7 @@ export function initFunctions(initialize: InitializeArguments) { filtersFunctionFactory(initialize), timelionFunctionFactory(initialize), toFunctionFactory(initialize), + pieFunctionFactory(initialize.paletteService), + plotFunctionFactory(initialize.paletteService), ]; } diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/pie.test.js b/x-pack/plugins/canvas/public/functions/pie.test.js similarity index 82% rename from x-pack/plugins/canvas/canvas_plugin_src/functions/common/pie.test.js rename to x-pack/plugins/canvas/public/functions/pie.test.js index 4ef6583096213..99f61cfb5d922 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/pie.test.js +++ b/x-pack/plugins/canvas/public/functions/pie.test.js @@ -4,13 +4,23 @@ * you may not use this file except in compliance with the Elastic License. */ -import { functionWrapper } from '../../../__tests__/helpers/function_wrapper'; -import { testPie } from './__tests__/fixtures/test_pointseries'; -import { fontStyle, grayscalePalette, seriesStyle } from './__tests__/fixtures/test_styles'; -import { pie } from './pie'; +import { functionWrapper } from '../../__tests__/helpers/function_wrapper'; +import { testPie } from '../../canvas_plugin_src/functions/common/__tests__/fixtures/test_pointseries'; +import { + fontStyle, + grayscalePalette, + seriesStyle, +} from '../../canvas_plugin_src/functions/common/__tests__/fixtures/test_styles'; +import { pieFunctionFactory } from './pie'; describe('pie', () => { - const fn = functionWrapper(pie); + const fn = functionWrapper( + pieFunctionFactory({ + get: () => ({ + getColors: () => ['red', 'black'], + }), + }) + ); it('returns a render as pie', () => { const result = fn(testPie); @@ -44,9 +54,18 @@ describe('pie', () => { describe('args', () => { describe('palette', () => { it('sets the color palette', () => { - const result = fn(testPie, { palette: grayscalePalette }).value.options; + const mockedColors = jest.fn(() => ['#FFFFFF', '#888888', '#000000']); + const mockedFn = functionWrapper( + pieFunctionFactory({ + get: () => ({ + getColors: mockedColors, + }), + }) + ); + const result = mockedFn(testPie, { palette: grayscalePalette }).value.options; expect(result).toHaveProperty('colors'); - expect(result.colors).toEqual(grayscalePalette.colors); + expect(result.colors).toEqual(['#FFFFFF', '#888888', '#000000']); + expect(mockedColors).toHaveBeenCalledWith(5, grayscalePalette.params); }); // TODO: write test when using an instance of the interpreter diff --git a/x-pack/plugins/canvas/public/functions/pie.ts b/x-pack/plugins/canvas/public/functions/pie.ts new file mode 100644 index 0000000000000..ab3f1b932dc3c --- /dev/null +++ b/x-pack/plugins/canvas/public/functions/pie.ts @@ -0,0 +1,206 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { get, keyBy, map, groupBy } from 'lodash'; +import { PaletteOutput, PaletteRegistry } from 'src/plugins/charts/public'; +// @ts-expect-error untyped local +import { getLegendConfig } from '../../common/lib/get_legend_config'; +import { getFunctionHelp } from '../../i18n'; +import { + Legend, + PointSeries, + Render, + SeriesStyle, + Style, + ExpressionFunctionDefinition, +} from '../../types'; + +interface PieSeriesOptions { + show: boolean; + innerRadius: number; + stroke: { + width: number; + }; + label: { + show: boolean; + radius: number; + }; + tilt: number; + radius: number | 'auto'; +} + +interface PieOptions { + canvas: boolean; + colors: string[]; + legend: { + show: boolean; + backgroundOpacity?: number; + labelBoxBorderColor?: string; + position?: Legend; + }; + grid: { + show: boolean; + }; + series: { + pie: PieSeriesOptions; + }; +} + +interface PieData { + label: string; + data: number[]; + color?: string; +} + +export interface Pie { + font: Style; + data: PieData[]; + options: PieOptions; +} + +interface Arguments { + palette: PaletteOutput; + seriesStyle: SeriesStyle[]; + radius: number | 'auto'; + hole: number; + labels: boolean; + labelRadius: number; + font: Style; + legend: Legend | false; + tilt: number; +} + +export function pieFunctionFactory( + paletteService: PaletteRegistry +): () => ExpressionFunctionDefinition<'pie', PointSeries, Arguments, Render> { + return () => { + const { help, args: argHelp } = getFunctionHelp().pie; + + return { + name: 'pie', + aliases: [], + type: 'render', + inputTypes: ['pointseries'], + help, + args: { + font: { + types: ['style'], + help: argHelp.font, + default: '{font}', + }, + hole: { + types: ['number'], + default: 0, + help: argHelp.hole, + }, + labelRadius: { + types: ['number'], + default: 100, + help: argHelp.labelRadius, + }, + labels: { + types: ['boolean'], + default: true, + help: argHelp.labels, + }, + legend: { + types: ['string', 'boolean'], + help: argHelp.legend, + default: false, + options: [...Object.values(Legend), false], + }, + palette: { + types: ['palette'], + help: argHelp.palette, + default: '{palette}', + }, + radius: { + types: ['string', 'number'], + help: argHelp.radius, + default: 'auto', + }, + seriesStyle: { + multi: true, + types: ['seriesStyle'], + help: argHelp.seriesStyle, + }, + tilt: { + types: ['number'], + default: 1, + help: argHelp.tilt, + }, + }, + fn: (input, args) => { + const { + tilt, + radius, + labelRadius, + labels, + hole, + legend, + palette, + font, + seriesStyle, + } = args; + const seriesStyles = keyBy(seriesStyle || [], 'label') || {}; + + const data: PieData[] = map(groupBy(input.rows, 'color'), (series, label = '') => { + const item: PieData = { + label, + data: series.map((point) => point.size || 1), + }; + + const style = seriesStyles[label]; + + // append series style, if there is a match + if (style) { + item.color = get(style, 'color'); + } + + return item; + }); + + return { + type: 'render', + as: 'pie', + value: { + font, + data, + options: { + canvas: false, + colors: paletteService + .get(palette.name || 'custom') + .getColors(data.length, palette.params), + legend: getLegendConfig(legend, data.length), + grid: { + show: false, + }, + series: { + pie: { + show: true, + innerRadius: Math.max(hole, 0) / 100, + stroke: { + width: 0, + }, + label: { + show: labels, + radius: (labelRadius >= 0 ? labelRadius : 100) / 100, + }, + tilt, + radius, + }, + bubbles: { + show: false, + }, + shadowSize: 0, + }, + }, + }, + }; + }, + }; + }; +} diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/plot.test.js b/x-pack/plugins/canvas/public/functions/plot.test.js similarity index 89% rename from x-pack/plugins/canvas/canvas_plugin_src/functions/common/plot.test.js rename to x-pack/plugins/canvas/public/functions/plot.test.js index d983cb7429863..426e9a23efe5d 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/plot.test.js +++ b/x-pack/plugins/canvas/public/functions/plot.test.js @@ -4,21 +4,26 @@ * you may not use this file except in compliance with the Elastic License. */ -import { functionWrapper } from '../../../__tests__/helpers/function_wrapper'; -import { testPlot } from './__tests__/fixtures/test_pointseries'; +import { functionWrapper } from '../../__tests__/helpers/function_wrapper'; +import { testPlot } from '../../canvas_plugin_src/functions/common/__tests__/fixtures/test_pointseries'; import { fontStyle, grayscalePalette, - gradientPalette, yAxisConfig, xAxisConfig, seriesStyle, defaultStyle, -} from './__tests__/fixtures/test_styles'; -import { plot } from './plot'; +} from '../../canvas_plugin_src/functions/common/__tests__/fixtures/test_styles'; +import { plotFunctionFactory } from './plot'; describe('plot', () => { - const fn = functionWrapper(plot); + const fn = functionWrapper( + plotFunctionFactory({ + get: () => ({ + getColors: () => ['red', 'black'], + }), + }) + ); it('returns a render as plot', () => { const result = fn(testPlot); @@ -111,15 +116,18 @@ describe('plot', () => { describe('palette', () => { it('sets the color palette', () => { - const result = fn(testPlot, { palette: grayscalePalette }).value.options; + const mockedColors = jest.fn(() => ['#FFFFFF', '#888888', '#000000']); + const mockedFn = functionWrapper( + plotFunctionFactory({ + get: () => ({ + getColors: mockedColors, + }), + }) + ); + const result = mockedFn(testPlot, { palette: grayscalePalette }).value.options; expect(result).toHaveProperty('colors'); - expect(result.colors).toEqual(grayscalePalette.colors); - }); - - it('creates a new set of colors from a color scale when gradient is true', () => { - const result = fn(testPlot, { palette: gradientPalette }).value.options; - expect(result).toHaveProperty('colors'); - expect(result.colors).toEqual(['#ffffff', '#aaaaaa', '#555555', '#000000']); + expect(result.colors).toEqual(['#FFFFFF', '#888888', '#000000']); + expect(mockedColors).toHaveBeenCalledWith(4, grayscalePalette.params); }); // TODO: write test when using an instance of the interpreter diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/get_flot_axis_config.test.js b/x-pack/plugins/canvas/public/functions/plot/get_flot_axis_config.test.js similarity index 94% rename from x-pack/plugins/canvas/canvas_plugin_src/functions/common/get_flot_axis_config.test.js rename to x-pack/plugins/canvas/public/functions/plot/get_flot_axis_config.test.js index a5b65e6bdc62b..c0ab3bb316b73 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/get_flot_axis_config.test.js +++ b/x-pack/plugins/canvas/public/functions/plot/get_flot_axis_config.test.js @@ -4,8 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -import { xAxisConfig, yAxisConfig, hideAxis } from './__tests__/fixtures/test_styles'; -import { getFlotAxisConfig } from './plot/get_flot_axis_config'; +import { + xAxisConfig, + yAxisConfig, + hideAxis, +} from '../../../canvas_plugin_src/functions/common/__tests__/fixtures/test_styles'; +import { getFlotAxisConfig } from './get_flot_axis_config'; describe('getFlotAxisConfig', () => { const columns = { diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/plot/get_flot_axis_config.ts b/x-pack/plugins/canvas/public/functions/plot/get_flot_axis_config.ts similarity index 92% rename from x-pack/plugins/canvas/canvas_plugin_src/functions/common/plot/get_flot_axis_config.ts rename to x-pack/plugins/canvas/public/functions/plot/get_flot_axis_config.ts index 660605c4c54c1..908ce5793b4f5 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/plot/get_flot_axis_config.ts +++ b/x-pack/plugins/canvas/public/functions/plot/get_flot_axis_config.ts @@ -5,8 +5,8 @@ */ import { get, map } from 'lodash'; -import { Ticks, AxisConfig, isAxisConfig } from '../../../../types'; -import { Style, PointSeriesColumns } from '../../../../../../../src/plugins/expressions/common'; +import { Ticks, AxisConfig, isAxisConfig } from '../../../types'; +import { Style, PointSeriesColumns } from '../../../../../../src/plugins/expressions/common'; type Position = 'bottom' | 'top' | 'left' | 'right'; interface Config { diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/get_font_spec.test.js b/x-pack/plugins/canvas/public/functions/plot/get_font_spec.test.js similarity index 81% rename from x-pack/plugins/canvas/canvas_plugin_src/functions/common/get_font_spec.test.js rename to x-pack/plugins/canvas/public/functions/plot/get_font_spec.test.js index af549a50b0ab4..dfaf510a94718 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/get_font_spec.test.js +++ b/x-pack/plugins/canvas/public/functions/plot/get_font_spec.test.js @@ -4,8 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { fontStyle } from './__tests__/fixtures/test_styles'; -import { defaultSpec, getFontSpec } from './plot/get_font_spec'; +import { fontStyle } from '../../../canvas_plugin_src/functions/common/__tests__/fixtures/test_styles'; +import { defaultSpec, getFontSpec } from './get_font_spec'; describe('getFontSpec', () => { describe('default output', () => { diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/plot/get_font_spec.ts b/x-pack/plugins/canvas/public/functions/plot/get_font_spec.ts similarity index 92% rename from x-pack/plugins/canvas/canvas_plugin_src/functions/common/plot/get_font_spec.ts rename to x-pack/plugins/canvas/public/functions/plot/get_font_spec.ts index 8402210391318..87390e626938a 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/plot/get_font_spec.ts +++ b/x-pack/plugins/canvas/public/functions/plot/get_font_spec.ts @@ -4,8 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { openSans } from '../../../../common/lib/fonts'; -import { Style } from '../../../../types'; +import { openSans } from '../../../common/lib/fonts'; +import { Style } from '../../../types'; // converts the output of the font function to a flot font spec // for font spec, see https://github.com/flot/flot/blob/master/API.md#customizing-the-axes diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/get_tick_hash.test.js b/x-pack/plugins/canvas/public/functions/plot/get_tick_hash.test.js similarity index 96% rename from x-pack/plugins/canvas/canvas_plugin_src/functions/common/get_tick_hash.test.js rename to x-pack/plugins/canvas/public/functions/plot/get_tick_hash.test.js index 74c13be3abd7b..3470e78956687 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/get_tick_hash.test.js +++ b/x-pack/plugins/canvas/public/functions/plot/get_tick_hash.test.js @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { getTickHash } from './plot/get_tick_hash'; +import { getTickHash } from './get_tick_hash'; describe('getTickHash', () => { it('creates a hash for tick marks for string columns only', () => { diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/plot/get_tick_hash.ts b/x-pack/plugins/canvas/public/functions/plot/get_tick_hash.ts similarity index 98% rename from x-pack/plugins/canvas/canvas_plugin_src/functions/common/plot/get_tick_hash.ts rename to x-pack/plugins/canvas/public/functions/plot/get_tick_hash.ts index 21166454e478f..2ed58004d6a42 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/plot/get_tick_hash.ts +++ b/x-pack/plugins/canvas/public/functions/plot/get_tick_hash.ts @@ -5,7 +5,7 @@ */ import { get, sortBy } from 'lodash'; -import { PointSeriesColumns, DatatableRow, Ticks } from '../../../../types'; +import { PointSeriesColumns, DatatableRow, Ticks } from '../../../types'; export const getTickHash = (columns: PointSeriesColumns, rows: DatatableRow[]) => { const ticks: Ticks = { diff --git a/x-pack/plugins/canvas/public/functions/plot/index.ts b/x-pack/plugins/canvas/public/functions/plot/index.ts new file mode 100644 index 0000000000000..a4661dc3401df --- /dev/null +++ b/x-pack/plugins/canvas/public/functions/plot/index.ts @@ -0,0 +1,177 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { set } from '@elastic/safer-lodash-set'; +import { groupBy, get, keyBy, map, sortBy } from 'lodash'; +import { ExpressionFunctionDefinition, Style } from 'src/plugins/expressions'; +import { PaletteOutput, PaletteRegistry } from 'src/plugins/charts/public'; +// @ts-expect-error untyped local +import { getLegendConfig } from '../../../common/lib/get_legend_config'; +import { getFlotAxisConfig } from './get_flot_axis_config'; +import { getFontSpec } from './get_font_spec'; +import { seriesStyleToFlot } from './series_style_to_flot'; +import { getTickHash } from './get_tick_hash'; +import { getFunctionHelp } from '../../../i18n'; +import { AxisConfig, PointSeries, Render, SeriesStyle, Legend } from '../../../types'; + +interface Arguments { + seriesStyle: SeriesStyle[]; + defaultStyle: SeriesStyle; + palette: PaletteOutput; + font: Style; + legend: Legend | boolean; + xaxis: AxisConfig | boolean; + yaxis: AxisConfig | boolean; +} + +export function plotFunctionFactory( + paletteService: PaletteRegistry +): () => ExpressionFunctionDefinition<'plot', PointSeries, Arguments, Render> { + return () => { + const { help, args: argHelp } = getFunctionHelp().plot; + + return { + name: 'plot', + aliases: [], + type: 'render', + inputTypes: ['pointseries'], + help, + args: { + defaultStyle: { + multi: false, + types: ['seriesStyle'], + help: argHelp.defaultStyle, + default: '{seriesStyle points=5}', + }, + font: { + types: ['style'], + help: argHelp.font, + default: '{font}', + }, + legend: { + types: ['string', 'boolean'], + help: argHelp.legend, + default: 'ne', + options: [...Object.values(Legend), false], + }, + palette: { + types: ['palette'], + help: argHelp.palette, + default: '{palette}', + }, + seriesStyle: { + multi: true, + types: ['seriesStyle'], + help: argHelp.seriesStyle, + }, + xaxis: { + types: ['boolean', 'axisConfig'], + help: argHelp.xaxis, + default: true, + }, + yaxis: { + types: ['boolean', 'axisConfig'], + help: argHelp.yaxis, + default: true, + }, + }, + fn: (input, args) => { + const seriesStyles: { [key: string]: SeriesStyle } = + keyBy(args.seriesStyle || [], 'label') || {}; + + const sortedRows = sortBy(input.rows, ['x', 'y', 'color', 'size', 'text']); + const ticks = getTickHash(input.columns, sortedRows); + const font = args.font ? getFontSpec(args.font) : {}; + + const data = map(groupBy(sortedRows, 'color'), (series, label) => { + const seriesStyle = { + ...args.defaultStyle, + ...seriesStyles[label as string], + }; + + const flotStyle = seriesStyle ? seriesStyleToFlot(seriesStyle) : {}; + + return { + ...flotStyle, + label, + data: series.map((point) => { + const attrs: { + size?: number; + text?: string; + } = {}; + + const x = get(input.columns, 'x.type') === 'string' ? ticks.x.hash[point.x] : point.x; + const y = get(input.columns, 'y.type') === 'string' ? ticks.y.hash[point.y] : point.y; + + if (point.size != null) { + attrs.size = point.size; + } else if (get(seriesStyle, 'points')) { + attrs.size = seriesStyle.points; + set(flotStyle, 'bubbles.size.min', seriesStyle.points); + } + + if (point.text != null) { + attrs.text = point.text; + } + + return [x, y, attrs]; + }), + }; + }); + + const gridConfig = { + borderWidth: 0, + borderColor: null, + color: 'rgba(0,0,0,0)', + labelMargin: 30, + margin: { + right: 30, + top: 20, + bottom: 0, + left: 0, + }, + }; + + const output = { + type: 'render', + as: 'plot', + value: { + font: args.font, + data: sortBy(data, 'label'), + options: { + canvas: false, + colors: paletteService + .get(args.palette.name || 'custom') + .getColors(data.length, args.palette.params), + legend: getLegendConfig(args.legend, data.length), + grid: gridConfig, + xaxis: getFlotAxisConfig('x', args.xaxis, { + columns: input.columns, + ticks, + font, + }), + yaxis: getFlotAxisConfig('y', args.yaxis, { + columns: input.columns, + ticks, + font, + }), + series: { + shadowSize: 0, + ...seriesStyleToFlot(args.defaultStyle), + }, + }, + }, + }; + + // fix the issue of plot sometimes re-rendering with an empty chart + // TODO: holy hell, why does this work?! the working theory is that some values become undefined + // and serializing the result here causes them to be dropped off, and this makes flot react differently. + // It's also possible that something else ends up mutating this object, but that seems less likely. + return JSON.parse(JSON.stringify(output)); + }, + }; + }; +} diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/series_style_to_flot.test.js b/x-pack/plugins/canvas/public/functions/plot/series_style_to_flot.test.js similarity index 98% rename from x-pack/plugins/canvas/canvas_plugin_src/functions/common/series_style_to_flot.test.js rename to x-pack/plugins/canvas/public/functions/plot/series_style_to_flot.test.js index af61f537167ad..1cfa12a83e5c9 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/series_style_to_flot.test.js +++ b/x-pack/plugins/canvas/public/functions/plot/series_style_to_flot.test.js @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { seriesStyleToFlot } from './plot/series_style_to_flot'; +import { seriesStyleToFlot } from './series_style_to_flot'; describe('seriesStyleToFlot', () => { it('returns an empty object if seriesStyle is not provided', () => { diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/plot/series_style_to_flot.ts b/x-pack/plugins/canvas/public/functions/plot/series_style_to_flot.ts similarity index 96% rename from x-pack/plugins/canvas/canvas_plugin_src/functions/common/plot/series_style_to_flot.ts rename to x-pack/plugins/canvas/public/functions/plot/series_style_to_flot.ts index e4b710240de19..f6be3260c799d 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/plot/series_style_to_flot.ts +++ b/x-pack/plugins/canvas/public/functions/plot/series_style_to_flot.ts @@ -5,7 +5,7 @@ */ import { get } from 'lodash'; -import { SeriesStyle } from '../../../../types'; +import { SeriesStyle } from '../../../types'; export const seriesStyleToFlot = (seriesStyle: SeriesStyle) => { if (!seriesStyle) { diff --git a/x-pack/plugins/canvas/public/plugin.tsx b/x-pack/plugins/canvas/public/plugin.tsx index fbca1e51bd5c6..d18f1b8d24489 100644 --- a/x-pack/plugins/canvas/public/plugin.tsx +++ b/x-pack/plugins/canvas/public/plugin.tsx @@ -5,6 +5,7 @@ */ import { BehaviorSubject } from 'rxjs'; +import { ChartsPluginSetup, ChartsPluginStart } from 'src/plugins/charts/public'; import { CoreSetup, CoreStart, @@ -43,6 +44,7 @@ export interface CanvasSetupDeps { home?: HomePublicPluginSetup; usageCollection?: UsageCollectionSetup; bfetch: BfetchPublicSetup; + charts: ChartsPluginSetup; } export interface CanvasStartDeps { @@ -50,6 +52,7 @@ export interface CanvasStartDeps { expressions: ExpressionsStart; inspector: InspectorStart; uiActions: UiActionsStart; + charts: ChartsPluginStart; } /** diff --git a/x-pack/plugins/lens/kibana.json b/x-pack/plugins/lens/kibana.json index c23c43120050c..2da67f81122ab 100644 --- a/x-pack/plugins/lens/kibana.json +++ b/x-pack/plugins/lens/kibana.json @@ -6,6 +6,7 @@ "ui": true, "requiredPlugins": [ "data", + "charts", "expressions", "navigation", "urlForwarding", diff --git a/x-pack/plugins/lens/public/datatable_visualization/visualization.test.tsx b/x-pack/plugins/lens/public/datatable_visualization/visualization.test.tsx index 0db456e0760ec..4bda56ca5087c 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/visualization.test.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/visualization.test.tsx @@ -6,12 +6,13 @@ import { Ast } from '@kbn/interpreter/common'; import { buildExpression } from '../../../../../src/plugins/expressions/public'; -import { createMockDatasource } from '../editor_frame_service/mocks'; +import { createMockDatasource, createMockFramePublicAPI } from '../editor_frame_service/mocks'; import { DatatableVisualizationState, datatableVisualization } from './visualization'; import { Operation, DataType, FramePublicAPI, TableSuggestionColumn } from '../types'; function mockFrame(): FramePublicAPI { return { + ...createMockFramePublicAPI(), addNewLayer: () => 'aaa', removeLayers: () => {}, datasourceLayers: {}, diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx index 79e6bc3a3379a..f7a6f0597bf9c 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx @@ -23,6 +23,7 @@ import { DragDrop } from '../../drag_drop'; import { FrameLayout } from './frame_layout'; import { uiActionsPluginMock } from '../../../../../../src/plugins/ui_actions/public/mocks'; import { dataPluginMock } from '../../../../../../src/plugins/data/public/mocks'; +import { chartPluginMock } from '../../../../../../src/plugins/charts/public/mocks'; import { expressionsPluginMock } from '../../../../../../src/plugins/expressions/public/mocks'; function generateSuggestion(state = {}): DatasourceSuggestion { @@ -55,7 +56,9 @@ function getDefaultProps() { uiActions: uiActionsPluginMock.createStartContract(), data: dataPluginMock.createStartContract(), expressions: expressionsPluginMock.createStartContract(), + charts: chartPluginMock.createStartContract(), }, + palettes: chartPluginMock.createPaletteRegistry(), showNoDataPopover: jest.fn(), }; } @@ -233,10 +236,11 @@ describe('editor_frame', () => { }); it('should pass the public frame api into visualization initialize', async () => { + const defaultProps = getDefaultProps(); await act(async () => { mount( { query: { query: '', language: 'lucene' }, filters: [], dateRange: { fromDate: 'now-7d', toDate: 'now' }, + availablePalettes: defaultProps.palettes, }); }); @@ -963,6 +968,7 @@ describe('editor_frame', () => { expect.objectContaining({ datasourceLayers: expect.objectContaining({ first: mockDatasource.publicAPIMock }), }), + undefined, undefined ); expect(mockVisualization2.getConfiguration).toHaveBeenCalledWith( diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.tsx index 32fd4461dfc8b..bb40b9f31d254 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.tsx @@ -6,6 +6,7 @@ import React, { useEffect, useReducer, useState } from 'react'; import { CoreSetup, CoreStart } from 'kibana/public'; +import { PaletteRegistry } from 'src/plugins/charts/public'; import { ReactExpressionRendererType } from '../../../../../../src/plugins/expressions/public'; import { Datasource, FramePublicAPI, Visualization } from '../../types'; import { reducer, getInitialState } from './state_management'; @@ -31,6 +32,7 @@ export interface EditorFrameProps { initialDatasourceId: string | null; initialVisualizationId: string | null; ExpressionRenderer: ReactExpressionRendererType; + palettes: PaletteRegistry; onError: (e: { message: string }) => void; core: CoreSetup | CoreStart; plugins: EditorFrameStartPlugins; @@ -103,6 +105,8 @@ export function EditorFrame(props: EditorFrameProps) { query: props.query, filters: props.filters, + availablePalettes: props.palettes, + addNewLayer() { const newLayerId = generateId(); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/save.test.ts b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/save.test.ts index 45d24fd30e2fc..aeab9301a71af 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/save.test.ts +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/save.test.ts @@ -5,7 +5,7 @@ */ import { getSavedObjectFormat, Props } from './save'; -import { createMockDatasource, createMockVisualization } from '../mocks'; +import { createMockDatasource, createMockFramePublicAPI, createMockVisualization } from '../mocks'; import { esFilters, IIndexPattern, IFieldType } from '../../../../../../src/plugins/data/public'; jest.mock('./expression_helpers'); @@ -37,6 +37,7 @@ describe('save editor frame state', () => { visualization: { activeId: '2', state: {} }, }, framePublicAPI: { + ...createMockFramePublicAPI(), addNewLayer: jest.fn(), removeLayers: jest.fn(), datasourceLayers: { diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_management.test.ts b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_management.test.ts index 80d007e17f711..6d0e1ad48dc21 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_management.test.ts +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_management.test.ts @@ -12,6 +12,7 @@ import { coreMock } from 'src/core/public/mocks'; import { uiActionsPluginMock } from '../../../../../../src/plugins/ui_actions/public/mocks'; import { dataPluginMock } from '../../../../../../src/plugins/data/public/mocks'; import { expressionsPluginMock } from '../../../../../../src/plugins/expressions/public/mocks'; +import { chartPluginMock } from 'src/plugins/charts/public/mocks'; describe('editor_frame state management', () => { describe('initialization', () => { @@ -31,7 +32,9 @@ describe('editor_frame state management', () => { uiActions: uiActionsPluginMock.createStartContract(), data: dataPluginMock.createStartContract(), expressions: expressionsPluginMock.createStartContract(), + charts: chartPluginMock.createStartContract(), }, + palettes: chartPluginMock.createPaletteRegistry(), dateRange: { fromDate: 'now-7d', toDate: 'now' }, query: { query: '', language: 'lucene' }, filters: [], diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.test.ts b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.test.ts index c5c66c1c820e8..c2534c8337df0 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.test.ts +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.test.ts @@ -7,6 +7,7 @@ import { getSuggestions } from './suggestion_helpers'; import { createMockVisualization, createMockDatasource, DatasourceMock } from '../mocks'; import { TableSuggestion, DatasourceSuggestion } from '../../types'; +import { PaletteOutput } from 'src/plugins/charts/public'; const generateSuggestion = (state = {}, layerId: string = 'first'): DatasourceSuggestion => ({ state, @@ -413,4 +414,62 @@ describe('suggestion helpers', () => { }) ); }); + + it('should pass passed in main palette if specified', () => { + const mockVisualization1 = createMockVisualization(); + const mockVisualization2 = createMockVisualization(); + const mainPalette: PaletteOutput = { type: 'palette', name: 'mock' }; + datasourceMap.mock.getDatasourceSuggestionsFromCurrentState.mockReturnValue([ + generateSuggestion(0), + generateSuggestion(1), + ]); + getSuggestions({ + visualizationMap: { + vis1: mockVisualization1, + vis2: mockVisualization2, + }, + activeVisualizationId: 'vis1', + visualizationState: {}, + datasourceMap, + datasourceStates, + mainPalette, + }); + expect(mockVisualization1.getSuggestions).toHaveBeenCalledWith( + expect.objectContaining({ + mainPalette, + }) + ); + expect(mockVisualization2.getSuggestions).toHaveBeenCalledWith( + expect.objectContaining({ + mainPalette, + }) + ); + }); + + it('should query active visualization for main palette if not specified', () => { + const mockVisualization1 = createMockVisualization(); + const mockVisualization2 = createMockVisualization(); + const mainPalette: PaletteOutput = { type: 'palette', name: 'mock' }; + mockVisualization1.getMainPalette = jest.fn(() => mainPalette); + datasourceMap.mock.getDatasourceSuggestionsFromCurrentState.mockReturnValue([ + generateSuggestion(0), + generateSuggestion(1), + ]); + getSuggestions({ + visualizationMap: { + vis1: mockVisualization1, + vis2: mockVisualization2, + }, + activeVisualizationId: 'vis1', + visualizationState: {}, + datasourceMap, + datasourceStates, + }); + expect(mockVisualization1.getMainPalette).toHaveBeenCalledWith({}); + expect(mockVisualization2.getSuggestions).toHaveBeenCalledWith( + expect.objectContaining({ + mainPalette, + }) + ); + }); }); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.ts b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.ts index c4a92dde6187c..95057f9db7e93 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.ts +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.ts @@ -7,6 +7,7 @@ import _ from 'lodash'; import { Ast } from '@kbn/interpreter/common'; import { IconType } from '@elastic/eui/src/components/icon/icon'; +import { PaletteOutput } from 'src/plugins/charts/public'; import { VisualizeFieldContext } from '../../../../../../src/plugins/ui_actions/public'; import { Visualization, @@ -49,6 +50,7 @@ export function getSuggestions({ visualizationState, field, visualizeTriggerFieldContext, + mainPalette, }: { datasourceMap: Record; datasourceStates: Record< @@ -64,6 +66,7 @@ export function getSuggestions({ visualizationState: unknown; field?: unknown; visualizeTriggerFieldContext?: VisualizeFieldContext; + mainPalette?: PaletteOutput; }): Suggestion[] { const datasources = Object.entries(datasourceMap).filter( ([datasourceId]) => datasourceStates[datasourceId] && !datasourceStates[datasourceId].isLoading @@ -100,13 +103,21 @@ export function getSuggestions({ const table = datasourceSuggestion.table; const currentVisualizationState = visualizationId === activeVisualizationId ? visualizationState : undefined; + const palette = + mainPalette || + (activeVisualizationId && + visualizationMap[activeVisualizationId] && + visualizationMap[activeVisualizationId].getMainPalette + ? visualizationMap[activeVisualizationId].getMainPalette!(visualizationState) + : undefined); return getVisualizationSuggestions( visualization, table, visualizationId, datasourceSuggestion, currentVisualizationState, - subVisualizationId + subVisualizationId, + palette ); }) ) @@ -165,7 +176,8 @@ function getVisualizationSuggestions( visualizationId: string, datasourceSuggestion: DatasourceSuggestion & { datasourceId: string }, currentVisualizationState: unknown, - subVisualizationId?: string + subVisualizationId?: string, + mainPalette?: PaletteOutput ) { return visualization .getSuggestions({ @@ -173,6 +185,7 @@ function getVisualizationSuggestions( state: currentVisualizationState, keptLayerIds: datasourceSuggestion.keptLayerIds, subVisualizationId, + mainPalette, }) .map(({ state, ...visualizationSuggestion }) => ({ ...visualizationSuggestion, diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.test.tsx index c78de9d140f76..5109d8808a233 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.test.tsx @@ -16,6 +16,7 @@ import { mountWithIntl as mount } from 'test_utils/enzyme_helpers'; import { Visualization, FramePublicAPI, DatasourcePublicAPI } from '../../../types'; import { Action } from '../state_management'; import { ChartSwitch } from './chart_switch'; +import { PaletteOutput } from 'src/plugins/charts/public'; describe('chart_switch', () => { function generateVisualization(id: string): jest.Mocked { @@ -449,6 +450,39 @@ describe('chart_switch', () => { ); }); + it('should query main palette from active chart and pass into suggestions', () => { + const dispatch = jest.fn(); + const visualizations = mockVisualizations(); + const mockPalette: PaletteOutput = { type: 'palette', name: 'mock' }; + visualizations.visA.getMainPalette = jest.fn(() => mockPalette); + visualizations.visB.getSuggestions.mockReturnValueOnce([]); + const frame = mockFrame(['a', 'b', 'c']); + const currentVisState = {}; + + const component = mount( + + ); + + switchTo('visB', component); + + expect(visualizations.visA.getMainPalette).toHaveBeenCalledWith(currentVisState); + + expect(visualizations.visB.getSuggestions).toHaveBeenCalledWith( + expect.objectContaining({ + keptLayerIds: ['a'], + mainPalette: mockPalette, + }) + ); + }); + it('should not remove layers when switching between subtypes', () => { const dispatch = jest.fn(); const frame = mockFrame(['a', 'b', 'c']); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.tsx index 73ffbf56ff45a..fe8747de667a3 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.tsx @@ -167,7 +167,15 @@ export function ChartSwitch(props: Props) { subVisualizationId, newVisualization.initialize( props.framePublicAPI, - props.visualizationId === newVisualization.id ? props.visualizationState : undefined + props.visualizationId === newVisualization.id + ? props.visualizationState + : undefined, + props.visualizationId && + props.visualizationMap[props.visualizationId].getMainPalette + ? props.visualizationMap[props.visualizationId].getMainPalette!( + props.visualizationState + ) + : undefined ) ); }, @@ -304,6 +312,12 @@ function getTopSuggestion( newVisualization: Visualization, subVisualizationId?: string ): Suggestion | undefined { + const mainPalette = + props.visualizationId && + props.visualizationMap[props.visualizationId] && + props.visualizationMap[props.visualizationId].getMainPalette + ? props.visualizationMap[props.visualizationId].getMainPalette!(props.visualizationState) + : undefined; const unfilteredSuggestions = getSuggestions({ datasourceMap: props.datasourceMap, datasourceStates: props.datasourceStates, @@ -311,6 +325,7 @@ function getTopSuggestion( activeVisualizationId: props.visualizationId, visualizationState: props.visualizationState, subVisualizationId, + mainPalette, }); const suggestions = unfilteredSuggestions.filter((suggestion) => { // don't use extended versions of current data table on switching between visualizations diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx index 2bbf183b7ae11..c4235a5514a54 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx @@ -98,6 +98,12 @@ export function WorkspacePanel({ (datasource) => datasource.getTableSpec().length > 0 ); + const mainPalette = + activeVisualizationId && + visualizationMap[activeVisualizationId] && + visualizationMap[activeVisualizationId].getMainPalette + ? visualizationMap[activeVisualizationId].getMainPalette!(visualizationState) + : undefined; const suggestions = getSuggestions({ datasourceMap: { [activeDatasourceId]: datasourceMap[activeDatasourceId] }, datasourceStates, @@ -108,6 +114,7 @@ export function WorkspacePanel({ activeVisualizationId, visualizationState, field: dragDropContext.dragging, + mainPalette, }); return suggestions.find((s) => s.visualizationId === activeVisualizationId) || suggestions[0]; diff --git a/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.tsx b/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.tsx index 10c243a272138..02ac58328b4e0 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.tsx @@ -16,6 +16,7 @@ import { IndexPattern, } from 'src/plugins/data/public'; import { ExecutionContextSearch } from 'src/plugins/expressions'; +import { PaletteOutput } from 'src/plugins/charts/public'; import { Subscription } from 'rxjs'; import { toExpression, Ast } from '@kbn/interpreter/common'; @@ -50,7 +51,9 @@ export type LensByValueInput = { } & EmbeddableInput; export type LensByReferenceInput = SavedObjectEmbeddableInput & EmbeddableInput; -export type LensEmbeddableInput = LensByValueInput | LensByReferenceInput; +export type LensEmbeddableInput = (LensByValueInput | LensByReferenceInput) & { + palette?: PaletteOutput; +}; export interface LensEmbeddableOutput extends EmbeddableOutput { indexPatterns?: IIndexPattern[]; @@ -172,11 +175,13 @@ export class Embeddable if (!this.savedVis || !this.isInitialized) { return; } + const input = this.getInput(); render( , diff --git a/x-pack/plugins/lens/public/editor_frame_service/embeddable/expression_wrapper.tsx b/x-pack/plugins/lens/public/editor_frame_service/embeddable/expression_wrapper.tsx index 484fe0436b0db..7479577805bdd 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/embeddable/expression_wrapper.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/embeddable/expression_wrapper.tsx @@ -18,6 +18,7 @@ import { getOriginalRequestErrorMessage } from '../error_helper'; export interface ExpressionWrapperProps { ExpressionRenderer: ReactExpressionRendererType; expression: string | null; + variables?: Record; searchContext: ExecutionContextSearch; searchSessionId?: string; handleEvent: (event: ExpressionRendererEvent) => void; @@ -27,6 +28,7 @@ export function ExpressionWrapper({ ExpressionRenderer: ExpressionRendererComponent, expression, searchContext, + variables, handleEvent, searchSessionId, }: ExpressionWrapperProps) { @@ -51,6 +53,7 @@ export function ExpressionWrapper({ { return { @@ -95,7 +97,28 @@ export function createMockDatasource(id: string): DatasourceMock { export type FrameMock = jest.Mocked; +export function createMockPaletteDefinition(): jest.Mocked { + return { + getColors: jest.fn((_) => ['#ff0000', '#00ff00']), + title: 'Mock Palette', + id: 'default', + renderEditor: jest.fn(), + toExpression: jest.fn(() => ({ + type: 'expression', + chain: [ + { + type: 'function', + function: 'mock_palette', + arguments: {}, + }, + ], + })), + getColor: jest.fn().mockReturnValue('#ff0000'), + }; +} + export function createMockFramePublicAPI(): FrameMock { + const palette = createMockPaletteDefinition(); return { datasourceLayers: {}, addNewLayer: jest.fn(() => ''), @@ -103,6 +126,10 @@ export function createMockFramePublicAPI(): FrameMock { dateRange: { fromDate: 'now-7d', toDate: 'now' }, query: { query: '', language: 'lucene' }, filters: [], + availablePalettes: { + get: () => palette, + getAll: () => [palette], + }, }; } @@ -128,6 +155,7 @@ export function createMockSetupDependencies() { data: dataPluginMock.createSetupContract(), embeddable: embeddablePluginMock.createSetupContract(), expressions: expressionsPluginMock.createSetupContract(), + charts: chartPluginMock.createSetupContract(), } as unknown) as MockedSetupDependencies; } @@ -136,5 +164,6 @@ export function createMockStartDependencies() { data: dataPluginMock.createSetupContract(), embeddable: embeddablePluginMock.createStartContract(), expressions: expressionsPluginMock.createStartContract(), + charts: chartPluginMock.createStartContract(), } as unknown) as MockedStartDependencies; } diff --git a/x-pack/plugins/lens/public/editor_frame_service/service.tsx b/x-pack/plugins/lens/public/editor_frame_service/service.tsx index 90e96be9e7ade..0562e9bf4d32e 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/service.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/service.tsx @@ -25,6 +25,7 @@ import { Document } from '../persistence/saved_object_store'; import { mergeTables } from './merge_tables'; import { EmbeddableFactory, LensEmbeddableStartServices } from './embeddable/embeddable_factory'; import { UiActionsStart } from '../../../../../src/plugins/ui_actions/public'; +import { ChartsPluginSetup } from '../../../../../src/plugins/charts/public'; import { DashboardStart } from '../../../../../src/plugins/dashboard/public'; import { LensAttributeService } from '../lens_attribute_service'; @@ -32,6 +33,7 @@ export interface EditorFrameSetupPlugins { data: DataPublicPluginSetup; embeddable?: EmbeddableSetup; expressions: ExpressionsSetup; + charts: ChartsPluginSetup; } export interface EditorFrameStartPlugins { @@ -40,6 +42,7 @@ export interface EditorFrameStartPlugins { dashboard?: DashboardStart; expressions: ExpressionsStart; uiActions?: UiActionsStart; + charts: ChartsPluginSetup; } async function collectAsyncDefinitions( @@ -143,6 +146,8 @@ export class EditorFrameService { const { EditorFrame, getActiveDatasourceIdFromDoc } = await import('../async_services'); + const palettes = await plugins.charts.palettes.getPalettes(); + render( ; visualization: unknown; query: Query; + globalPalette?: { + activePaletteId: string; + state?: unknown; + }; filters: PersistableFilter[]; }; references: SavedObjectReference[]; diff --git a/x-pack/plugins/lens/public/pie_visualization/expression.tsx b/x-pack/plugins/lens/public/pie_visualization/expression.tsx index d93145f29aa89..3b5226eaa8e1f 100644 --- a/x-pack/plugins/lens/public/pie_visualization/expression.tsx +++ b/x-pack/plugins/lens/public/pie_visualization/expression.tsx @@ -17,7 +17,7 @@ import { import { LensMultiTable, FormatFactory, LensFilterEvent } from '../types'; import { PieExpressionProps, PieExpressionArgs } from './types'; import { PieComponent } from './render_function'; -import { ChartsPluginSetup } from '../../../../../src/plugins/charts/public'; +import { ChartsPluginSetup, PaletteRegistry } from '../../../../../src/plugins/charts/public'; export interface PieRender { type: 'render'; @@ -91,6 +91,11 @@ export const pie: ExpressionFunctionDefinition< types: ['number'], help: '', }, + palette: { + default: `{theme "palette" default={system_palette name="default"} }`, + help: '', + types: ['palette'], + }, }, inputTypes: ['lens_multitable'], fn(data: LensMultiTable, args: PieExpressionArgs) { @@ -108,6 +113,7 @@ export const pie: ExpressionFunctionDefinition< export const getPieRenderer = (dependencies: { formatFactory: Promise; chartsThemeService: ChartsPluginSetup['theme']; + paletteService: PaletteRegistry; }): ExpressionRenderDefinition => ({ name: 'lens_pie_renderer', displayName: i18n.translate('xpack.lens.pie.visualizationName', { @@ -131,6 +137,7 @@ export const getPieRenderer = (dependencies: { {...config} formatFactory={formatFactory} chartsThemeService={dependencies.chartsThemeService} + paletteService={dependencies.paletteService} onClickValue={onClickValue} /> , diff --git a/x-pack/plugins/lens/public/pie_visualization/index.ts b/x-pack/plugins/lens/public/pie_visualization/index.ts index 36dd9b93c3e39..2fae0a42e1d3b 100644 --- a/x-pack/plugins/lens/public/pie_visualization/index.ts +++ b/x-pack/plugins/lens/public/pie_visualization/index.ts @@ -29,7 +29,8 @@ export class PieVisualization { { expressions, formatFactory, editorFrame, charts }: PieVisualizationPluginSetupPlugins ) { editorFrame.registerVisualization(async () => { - const { pieVisualization, pie, getPieRenderer } = await import('../async_services'); + const { getPieVisualization, pie, getPieRenderer } = await import('../async_services'); + const palettes = await charts.palettes.getPalettes(); expressions.registerFunction(() => pie); @@ -37,9 +38,10 @@ export class PieVisualization { getPieRenderer({ formatFactory, chartsThemeService: charts.theme, + paletteService: palettes, }) ); - return pieVisualization; + return getPieVisualization({ paletteService: palettes }); }); } } diff --git a/x-pack/plugins/lens/public/pie_visualization/render_function.test.tsx b/x-pack/plugins/lens/public/pie_visualization/render_function.test.tsx index 8ab1a8b5a58d8..c44179ccd8dfc 100644 --- a/x-pack/plugins/lens/public/pie_visualization/render_function.test.tsx +++ b/x-pack/plugins/lens/public/pie_visualization/render_function.test.tsx @@ -5,7 +5,12 @@ */ import React from 'react'; -import { SeriesIdentifier, Settings } from '@elastic/charts'; +import { Partition, SeriesIdentifier, Settings } from '@elastic/charts'; +import { + NodeColorAccessor, + ShapeTreeNode, +} from '@elastic/charts/dist/chart_types/partition_chart/layout/types/viewmodel_types'; +import { HierarchyOfArrays } from '@elastic/charts/dist/chart_types/partition_chart/layout/utils/group_by_rollup'; import { shallow } from 'enzyme'; import { LensMultiTable } from '../types'; import { PieComponent } from './render_function'; @@ -55,6 +60,7 @@ describe('PieVisualization component', () => { nestedLegend: false, percentDecimals: 3, hideLabels: false, + palette: { name: 'mock', type: 'palette' }, }; function getDefaultArgs() { @@ -63,6 +69,7 @@ describe('PieVisualization component', () => { formatFactory: getFormatSpy, onClickValue: jest.fn(), chartsThemeService, + paletteService: chartPluginMock.createPaletteRegistry(), }; } @@ -92,6 +99,84 @@ describe('PieVisualization component', () => { expect(component.find(Settings).prop('showLegend')).toEqual(false); }); + test('it calls the color function with the right series layers', () => { + const defaultArgs = getDefaultArgs(); + const component = shallow( + + ); + + (component.find(Partition).prop('layers')![1].shape!.fillColor as NodeColorAccessor)( + ({ + dataName: 'third', + depth: 2, + parent: { + children: [ + ['first', {}], + ['second', {}], + ['third', {}], + ], + depth: 1, + value: 200, + dataName: 'css', + parent: { + children: [ + ['empty', {}], + ['css', {}], + ['gz', {}], + ], + depth: 0, + sortIndex: 0, + value: 500, + }, + sortIndex: 1, + }, + value: 41, + sortIndex: 2, + } as unknown) as ShapeTreeNode, + 0, + [] as HierarchyOfArrays + ); + + expect(defaultArgs.paletteService.get('mock').getColor).toHaveBeenCalledWith( + [ + { + name: 'css', + rankAtDepth: 1, + totalSeriesAtDepth: 3, + }, + { + name: 'third', + rankAtDepth: 2, + totalSeriesAtDepth: 3, + }, + ], + { + maxDepth: 2, + totalSeries: 5, + behindText: true, + }, + undefined + ); + }); + test('it hides legend with 2 groups for treemap', () => { const component = shallow( diff --git a/x-pack/plugins/lens/public/pie_visualization/render_function.tsx b/x-pack/plugins/lens/public/pie_visualization/render_function.tsx index d4c85ce9b8843..eec351cfbb27e 100644 --- a/x-pack/plugins/lens/public/pie_visualization/render_function.tsx +++ b/x-pack/plugins/lens/public/pie_visualization/render_function.tsx @@ -4,25 +4,22 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useState, useEffect } from 'react'; -import color from 'color'; +import { uniq } from 'lodash'; +import React, { useEffect, useState } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiText } from '@elastic/eui'; -// @ts-ignore no types -import { euiPaletteColorBlindBehindText } from '@elastic/eui/lib/services'; -import euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json'; import { Chart, Datum, - Settings, + LayerValue, Partition, PartitionConfig, PartitionLayer, PartitionLayout, PartitionFillLabel, RecursivePartial, - LayerValue, Position, + Settings, } from '@elastic/charts'; import { FormatFactory, LensFilterEvent } from '../types'; import { VisualizationContainer } from '../visualization_container'; @@ -32,24 +29,27 @@ import { getSliceValue, getFilterContext } from './render_helpers'; import { EmptyPlaceholder } from '../shared_components'; import './visualization.scss'; import { desanitizeFilterContext } from '../utils'; -import { ChartsPluginSetup } from '../../../../../src/plugins/charts/public'; +import { + ChartsPluginSetup, + PaletteRegistry, + SeriesLayer, +} from '../../../../../src/plugins/charts/public'; import { LensIconChartDonut } from '../assets/chart_donut'; const EMPTY_SLICE = Symbol('empty_slice'); -const sortedColors = euiPaletteColorBlindBehindText(); - export function PieComponent( props: PieExpressionProps & { formatFactory: FormatFactory; chartsThemeService: ChartsPluginSetup['theme']; + paletteService: PaletteRegistry; onClickValue: (data: LensFilterEvent['data']) => void; } ) { const [firstTable] = Object.values(props.data.tables); const formatters: Record> = {}; - const { chartsThemeService, onClickValue } = props; + const { chartsThemeService, paletteService, onClickValue } = props; const { shape, groups, @@ -61,8 +61,8 @@ export function PieComponent( nestedLegend, percentDecimals, hideLabels, + palette, } = props.args; - const isDarkMode = chartsThemeService.useDarkMode(); const chartTheme = chartsThemeService.useChartsTheme(); const chartBaseTheme = chartsThemeService.useChartsBaseTheme(); @@ -73,7 +73,7 @@ export function PieComponent( } const fillLabel: Partial = { - textInvertible: false, + textInvertible: true, valueFont: { fontWeight: 700, }, @@ -86,6 +86,11 @@ export function PieComponent( } const bucketColumns = firstTable.columns.filter((col) => groups.includes(col.id)); + const totalSeriesCount = uniq( + firstTable.rows.map((row) => { + return bucketColumns.map(({ id: columnId }) => row[columnId]).join(','); + }) + ).length; const layers: PartitionLayer[] = bucketColumns.map((col, layerIndex) => { return { @@ -100,34 +105,45 @@ export function PieComponent( } return String(d); }, - fillLabel: - isDarkMode && - shape === 'treemap' && - layerIndex < bucketColumns.length - 1 && - categoryDisplay !== 'hide' - ? { ...fillLabel, textColor: euiDarkVars.euiTextColor } - : fillLabel, + fillLabel, shape: { fillColor: (d) => { + const seriesLayers: SeriesLayer[] = []; + // Color is determined by round-robin on the index of the innermost slice // This has to be done recursively until we get to the slice index - let parentIndex = 0; let tempParent: typeof d | typeof d['parent'] = d; while (tempParent.parent && tempParent.depth > 0) { - parentIndex = tempParent.sortIndex; + seriesLayers.unshift({ + name: String(tempParent.parent.children[tempParent.sortIndex][0]), + rankAtDepth: tempParent.sortIndex, + totalSeriesAtDepth: tempParent.parent.children.length, + }); tempParent = tempParent.parent; } - // Look up round-robin color from default palette - const outputColor = sortedColors[parentIndex % sortedColors.length]; - if (shape === 'treemap') { // Only highlight the innermost color of the treemap, as it accurately represents area - return layerIndex < bucketColumns.length - 1 ? 'rgba(0,0,0,0)' : outputColor; + if (layerIndex < bucketColumns.length - 1) { + return 'rgba(0,0,0,0)'; + } + // only use the top level series layer for coloring + if (seriesLayers.length > 1) { + seriesLayers.pop(); + } } - const lighten = (d.depth - 1) / (bucketColumns.length * 2); - return color(outputColor, 'hsl').lighten(lighten).hex(); + const outputColor = paletteService.get(palette.name).getColor( + seriesLayers, + { + behindText: categoryDisplay !== 'hide', + maxDepth: bucketColumns.length, + totalSeries: totalSeriesCount, + }, + palette.params + ); + + return outputColor || 'rgba(0,0,0,0)'; }, }, }; diff --git a/x-pack/plugins/lens/public/pie_visualization/suggestions.test.ts b/x-pack/plugins/lens/public/pie_visualization/suggestions.test.ts index b8b43c3ed248b..3097c40663132 100644 --- a/x-pack/plugins/lens/public/pie_visualization/suggestions.test.ts +++ b/x-pack/plugins/lens/public/pie_visualization/suggestions.test.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import { PaletteOutput } from 'src/plugins/charts/public'; import { DataType } from '../types'; import { suggestions } from './suggestions'; @@ -311,7 +312,38 @@ describe('suggestions', () => { ); }); - it('should keep the layer settings when switching from treemap', () => { + it('should keep passed in palette', () => { + const mainPalette: PaletteOutput = { type: 'palette', name: 'mock' }; + const results = suggestions({ + table: { + layerId: 'first', + isMultiRow: true, + columns: [ + { + columnId: 'a', + operation: { label: 'Top 5', dataType: 'string' as DataType, isBucketed: true }, + }, + { + columnId: 'b', + operation: { label: 'Top 5', dataType: 'string' as DataType, isBucketed: true }, + }, + { + columnId: 'e', + operation: { label: 'Count', dataType: 'number' as DataType, isBucketed: false }, + }, + ], + changeType: 'initial', + }, + state: undefined, + keptLayerIds: ['first'], + mainPalette, + }); + + expect(results[0].state.palette).toEqual(mainPalette); + }); + + it('should keep the layer settings and palette when switching from treemap', () => { + const palette: PaletteOutput = { type: 'palette', name: 'mock' }; expect( suggestions({ table: { @@ -331,6 +363,7 @@ describe('suggestions', () => { }, state: { shape: 'treemap', + palette, layers: [ { layerId: 'first', @@ -351,6 +384,7 @@ describe('suggestions', () => { expect.objectContaining({ state: { shape: 'donut', + palette, layers: [ { layerId: 'first', diff --git a/x-pack/plugins/lens/public/pie_visualization/suggestions.ts b/x-pack/plugins/lens/public/pie_visualization/suggestions.ts index 067b0bb4906df..497fb2e7de840 100644 --- a/x-pack/plugins/lens/public/pie_visualization/suggestions.ts +++ b/x-pack/plugins/lens/public/pie_visualization/suggestions.ts @@ -23,6 +23,7 @@ export function suggestions({ table, state, keptLayerIds, + mainPalette, }: SuggestionRequest): Array< VisualizationSuggestion > { @@ -57,6 +58,7 @@ export function suggestions({ score: state && state.shape !== 'treemap' ? 0.6 : 0.4, state: { shape: newShape, + palette: mainPalette || state?.palette, layers: [ state?.layers[0] ? { @@ -108,6 +110,7 @@ export function suggestions({ score: state?.shape === 'treemap' ? 0.7 : 0.5, state: { shape: 'treemap', + palette: mainPalette || state?.palette, layers: [ state?.layers[0] ? { diff --git a/x-pack/plugins/lens/public/pie_visualization/to_expression.ts b/x-pack/plugins/lens/public/pie_visualization/to_expression.ts index d721a578a3788..1916bfa32c0bf 100644 --- a/x-pack/plugins/lens/public/pie_visualization/to_expression.ts +++ b/x-pack/plugins/lens/public/pie_visualization/to_expression.ts @@ -5,6 +5,7 @@ */ import { Ast } from '@kbn/interpreter/common'; +import { PaletteRegistry } from 'src/plugins/charts/public'; import { Operation, DatasourcePublicAPI } from '../types'; import { DEFAULT_PERCENT_DECIMALS } from './constants'; import { PieVisualizationState } from './types'; @@ -12,14 +13,19 @@ import { PieVisualizationState } from './types'; export function toExpression( state: PieVisualizationState, datasourceLayers: Record, + paletteService: PaletteRegistry, attributes: Partial<{ title: string; description: string }> = {} ) { - return expressionHelper(state, datasourceLayers, { ...attributes, isPreview: false }); + return expressionHelper(state, datasourceLayers, paletteService, { + ...attributes, + isPreview: false, + }); } function expressionHelper( state: PieVisualizationState, datasourceLayers: Record, + paletteService: PaletteRegistry, attributes: { isPreview: boolean; title?: string; description?: string } = { isPreview: false } ): Ast | null { const layer = state.layers[0]; @@ -50,6 +56,29 @@ function expressionHelper( legendPosition: [layer.legendPosition || 'right'], percentDecimals: [layer.percentDecimals ?? DEFAULT_PERCENT_DECIMALS], nestedLegend: [!!layer.nestedLegend], + ...(state.palette + ? { + palette: [ + { + type: 'expression', + chain: [ + { + type: 'function', + function: 'theme', + arguments: { + variable: ['palette'], + default: [ + paletteService + .get(state.palette.name) + .toExpression(state.palette.params), + ], + }, + }, + ], + }, + ], + } + : {}), }, }, ], @@ -58,7 +87,8 @@ function expressionHelper( export function toPreviewExpression( state: PieVisualizationState, - datasourceLayers: Record + datasourceLayers: Record, + paletteService: PaletteRegistry ) { - return expressionHelper(state, datasourceLayers, { isPreview: true }); + return expressionHelper(state, datasourceLayers, paletteService, { isPreview: true }); } diff --git a/x-pack/plugins/lens/public/pie_visualization/toolbar.tsx b/x-pack/plugins/lens/public/pie_visualization/toolbar.tsx index 50b8f4c6fc40b..ab7422c3eeb63 100644 --- a/x-pack/plugins/lens/public/pie_visualization/toolbar.tsx +++ b/x-pack/plugins/lens/public/pie_visualization/toolbar.tsx @@ -18,8 +18,9 @@ import { import { Position } from '@elastic/charts'; import { DEFAULT_PERCENT_DECIMALS } from './constants'; import { PieVisualizationState, SharedLayerState } from './types'; -import { VisualizationToolbarProps } from '../types'; +import { VisualizationDimensionEditorProps, VisualizationToolbarProps } from '../types'; import { ToolbarPopover, LegendSettingsPopover } from '../shared_components'; +import { PalettePicker } from '../shared_components'; const numberOptions: Array<{ value: SharedLayerState['numberDisplay']; inputDisplay: string }> = [ { @@ -244,3 +245,17 @@ const DecimalPlaceSlider = ({ /> ); }; + +export function DimensionEditor(props: VisualizationDimensionEditorProps) { + return ( + <> + { + props.setState({ ...props.state, palette: newPalette }); + }} + /> + + ); +} diff --git a/x-pack/plugins/lens/public/pie_visualization/types.ts b/x-pack/plugins/lens/public/pie_visualization/types.ts index 54bececa13c2a..792f4d7a0b971 100644 --- a/x-pack/plugins/lens/public/pie_visualization/types.ts +++ b/x-pack/plugins/lens/public/pie_visualization/types.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import { PaletteOutput } from 'src/plugins/charts/public'; import { LensMultiTable } from '../types'; export interface SharedLayerState { @@ -24,6 +25,7 @@ export type LayerState = SharedLayerState & { export interface PieVisualizationState { shape: 'donut' | 'pie' | 'treemap'; layers: LayerState[]; + palette?: PaletteOutput; } export type PieExpressionArgs = SharedLayerState & { @@ -31,6 +33,7 @@ export type PieExpressionArgs = SharedLayerState & { description?: string; shape: 'pie' | 'donut' | 'treemap'; hideLabels: boolean; + palette: PaletteOutput; }; export interface PieExpressionProps { diff --git a/x-pack/plugins/lens/public/pie_visualization/visualization.tsx b/x-pack/plugins/lens/public/pie_visualization/visualization.tsx index dd1b36e00ebb9..791480162b7fa 100644 --- a/x-pack/plugins/lens/public/pie_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/pie_visualization/visualization.tsx @@ -8,12 +8,13 @@ import React from 'react'; import { render } from 'react-dom'; import { i18n } from '@kbn/i18n'; import { I18nProvider } from '@kbn/i18n/react'; +import { PaletteRegistry } from 'src/plugins/charts/public'; import { Visualization, OperationMetadata } from '../types'; import { toExpression, toPreviewExpression } from './to_expression'; import { LayerState, PieVisualizationState } from './types'; import { suggestions } from './suggestions'; import { CHART_NAMES, MAX_PIE_BUCKETS, MAX_TREEMAP_BUCKETS } from './constants'; -import { PieToolbar } from './toolbar'; +import { DimensionEditor, PieToolbar } from './toolbar'; function newLayerState(layerId: string): LayerState { return { @@ -31,7 +32,11 @@ const bucketedOperations = (op: OperationMetadata) => op.isBucketed; const numberMetricOperations = (op: OperationMetadata) => !op.isBucketed && op.dataType === 'number'; -export const pieVisualization: Visualization = { +export const getPieVisualization = ({ + paletteService, +}: { + paletteService: PaletteRegistry; +}): Visualization => ({ id: 'lnsPie', visualizationTypes: [ @@ -82,15 +87,18 @@ export const pieVisualization: Visualization = { shape: visualizationTypeId as PieVisualizationState['shape'], }), - initialize(frame, state) { + initialize(frame, state, mainPalette) { return ( state || { shape: 'donut', layers: [newLayerState(frame.addNewLayer())], + palette: mainPalette, } ); }, + getMainPalette: (state) => (state ? state.palette : undefined), + getSuggestions: suggestions, getConfiguration({ state, frame, layerId }) { @@ -121,6 +129,7 @@ export const pieVisualization: Visualization = { filterOperations: bucketedOperations, required: true, dataTestSubj: 'lnsPie_groupByDimensionPanel', + enableDimensionEditor: true, }, { groupId: 'metric', @@ -151,6 +160,7 @@ export const pieVisualization: Visualization = { filterOperations: bucketedOperations, required: true, dataTestSubj: 'lnsPie_sliceByDimensionPanel', + enableDimensionEditor: true, }, { groupId: 'metric', @@ -202,9 +212,18 @@ export const pieVisualization: Visualization = { }), }; }, + renderDimensionEditor(domElement, props) { + render( + + + , + domElement + ); + }, - toExpression, - toPreviewExpression, + toExpression: (state, layers, attributes) => + toExpression(state, layers, paletteService, attributes), + toPreviewExpression: (state, layers) => toPreviewExpression(state, layers, paletteService), renderToolbar(domElement, props) { render( @@ -214,4 +233,4 @@ export const pieVisualization: Visualization = { domElement ); }, -}; +}); diff --git a/x-pack/plugins/lens/public/plugin.ts b/x-pack/plugins/lens/public/plugin.ts index dddbadec00cf8..36237eeb6b05f 100644 --- a/x-pack/plugins/lens/public/plugin.ts +++ b/x-pack/plugins/lens/public/plugin.ts @@ -13,7 +13,7 @@ import { VisualizationsSetup } from 'src/plugins/visualizations/public'; import { NavigationPublicPluginStart } from 'src/plugins/navigation/public'; import { UrlForwardingSetup } from 'src/plugins/url_forwarding/public'; import { GlobalSearchPluginSetup } from '../../global_search/public'; -import { ChartsPluginSetup } from '../../../../src/plugins/charts/public'; +import { ChartsPluginSetup, ChartsPluginStart } from '../../../../src/plugins/charts/public'; import { EditorFrameService } from './editor_frame_service'; import { IndexPatternDatasource, @@ -59,6 +59,7 @@ export interface LensPluginStartDependencies { uiActions: UiActionsStart; dashboard: DashboardStart; embeddable: EmbeddableStart; + charts: ChartsPluginStart; savedObjectsTagging?: SavedObjectTaggingPluginStart; } export class LensPlugin { @@ -104,6 +105,7 @@ export class LensPlugin { { data, embeddable, + charts, expressions, }, this.attributeService diff --git a/x-pack/plugins/lens/public/shared_components/index.ts b/x-pack/plugins/lens/public/shared_components/index.ts index c0362a5660adb..622bf5397c935 100644 --- a/x-pack/plugins/lens/public/shared_components/index.ts +++ b/x-pack/plugins/lens/public/shared_components/index.ts @@ -8,3 +8,4 @@ export * from './empty_placeholder'; export { ToolbarPopoverProps, ToolbarPopover } from './toolbar_popover'; export { ToolbarButtonProps, ToolbarButton } from './toolbar_button'; export { LegendSettingsPopover } from './legend_settings_popover'; +export { PalettePicker } from './palette_picker'; diff --git a/x-pack/plugins/lens/public/shared_components/palette_picker.tsx b/x-pack/plugins/lens/public/shared_components/palette_picker.tsx new file mode 100644 index 0000000000000..497e9b16650cf --- /dev/null +++ b/x-pack/plugins/lens/public/shared_components/palette_picker.tsx @@ -0,0 +1,76 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { PaletteOutput, PaletteRegistry } from 'src/plugins/charts/public'; +import { EuiColorPalettePicker } from '@elastic/eui'; +import { EuiFormRow } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { NativeRenderer } from '../native_renderer'; + +export function PalettePicker({ + palettes, + activePalette, + setPalette, +}: { + palettes: PaletteRegistry; + activePalette?: PaletteOutput; + setPalette: (palette: PaletteOutput) => void; +}) { + return ( + + <> + !internal) + .map(({ id, title, getColors }) => { + return { + value: id, + title, + type: 'fixed', + palette: getColors( + 10, + id === activePalette?.name ? activePalette?.params : undefined + ), + }; + })} + onChange={(newPalette) => { + setPalette({ + type: 'palette', + name: newPalette, + }); + }} + valueOfSelected={activePalette?.name || 'default'} + selectionDisplay={'palette'} + /> + {activePalette && palettes.get(activePalette.name).renderEditor && ( + { + setPalette({ + type: 'palette', + name: activePalette.name, + params: updater(activePalette.params), + }); + }, + }} + /> + )} + + + ); +} diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts index 04acd4e32e3f0..6696a9328c837 100644 --- a/x-pack/plugins/lens/public/types.ts +++ b/x-pack/plugins/lens/public/types.ts @@ -7,6 +7,7 @@ import { Ast } from '@kbn/interpreter/common'; import { IconType } from '@elastic/eui/src/components/icon/icon'; import { CoreSetup } from 'kibana/public'; +import { PaletteOutput, PaletteRegistry } from 'src/plugins/charts/public'; import { SavedObjectReference } from 'kibana/public'; import { ExpressionRendererEvent, @@ -379,6 +380,7 @@ export interface SuggestionRequest { * State is only passed if the visualization is active. */ state?: T; + mainPalette?: PaletteOutput; /** * The visualization needs to know which table is being suggested */ @@ -430,6 +432,11 @@ export interface FramePublicAPI { query: Query; filters: Filter[]; + /** + * A map of all available palettes (keys being the ids). + */ + availablePalettes: PaletteRegistry; + // Adds a new layer. This has a side effect of updating the datasource state addNewLayer: () => string; removeLayers: (layerIds: string[]) => void; @@ -467,7 +474,9 @@ export interface Visualization { * - Loadingn from a saved visualization * - When using suggestions, the suggested state is passed in */ - initialize: (frame: FramePublicAPI, state?: T) => T; + initialize: (frame: FramePublicAPI, state?: T, mainPalette?: PaletteOutput) => T; + + getMainPalette?: (state: T) => undefined | PaletteOutput; /** * Visualizations must provide at least one type for the chart switcher, diff --git a/x-pack/plugins/lens/public/xy_visualization/axes_configuration.test.ts b/x-pack/plugins/lens/public/xy_visualization/axes_configuration.test.ts index a823a6370270d..e31a723a03af6 100644 --- a/x-pack/plugins/lens/public/xy_visualization/axes_configuration.test.ts +++ b/x-pack/plugins/lens/public/xy_visualization/axes_configuration.test.ts @@ -227,6 +227,7 @@ describe('axes_configuration', () => { xScaleType: 'ordinal', yScaleType: 'linear', isHistogram: false, + palette: { type: 'palette', name: 'default' }, }; it('should map auto series to left axis', () => { diff --git a/x-pack/plugins/lens/public/xy_visualization/color_assignment.test.ts b/x-pack/plugins/lens/public/xy_visualization/color_assignment.test.ts new file mode 100644 index 0000000000000..b59e09e8c1976 --- /dev/null +++ b/x-pack/plugins/lens/public/xy_visualization/color_assignment.test.ts @@ -0,0 +1,182 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FormatFactory, LensMultiTable } from '../types'; +import { getColorAssignments } from './color_assignment'; +import { LayerArgs } from './types'; + +describe('color_assignment', () => { + const layers: LayerArgs[] = [ + { + yScaleType: 'linear', + xScaleType: 'linear', + isHistogram: true, + seriesType: 'bar', + palette: { type: 'palette', name: 'palette1' }, + layerId: '1', + splitAccessor: 'split1', + accessors: ['y1', 'y2'], + }, + { + yScaleType: 'linear', + xScaleType: 'linear', + isHistogram: true, + seriesType: 'bar', + palette: { type: 'palette', name: 'palette2' }, + layerId: '2', + splitAccessor: 'split2', + accessors: ['y3', 'y4'], + }, + ]; + + const data: LensMultiTable = { + type: 'lens_multitable', + tables: { + '1': { + type: 'datatable', + columns: [ + { id: 'split1', name: '', meta: { type: 'number' } }, + { id: 'y1', name: '', meta: { type: 'number' } }, + { id: 'y2', name: '', meta: { type: 'number' } }, + ], + rows: [ + { split1: 1 }, + { split1: 2 }, + { split1: 3 }, + { split1: 1 }, + { split1: 2 }, + { split1: 3 }, + ], + }, + '2': { + type: 'datatable', + columns: [ + { id: 'split2', name: '', meta: { type: 'number' } }, + { id: 'y1', name: '', meta: { type: 'number' } }, + { id: 'y2', name: '', meta: { type: 'number' } }, + ], + rows: [ + { split2: 1 }, + { split2: 2 }, + { split2: 3 }, + { split2: 1 }, + { split2: 2 }, + { split2: 3 }, + ], + }, + }, + }; + + const formatFactory = (() => + ({ + convert(x: unknown) { + return x; + }, + } as unknown)) as FormatFactory; + + describe('totalSeriesCount', () => { + it('should calculate total number of series per palette', () => { + const assignments = getColorAssignments(layers, data, formatFactory); + // two y accessors, with 3 splitted series + expect(assignments.palette1.totalSeriesCount).toEqual(2 * 3); + expect(assignments.palette2.totalSeriesCount).toEqual(2 * 3); + }); + + it('should calculate total number of series spanning multible layers', () => { + const assignments = getColorAssignments( + [layers[0], { ...layers[1], palette: layers[0].palette }], + data, + formatFactory + ); + // two y accessors, with 3 splitted series, two times + expect(assignments.palette1.totalSeriesCount).toEqual(2 * 3 + 2 * 3); + expect(assignments.palette2).toBeUndefined(); + }); + + it('should calculate total number of series for non split series', () => { + const assignments = getColorAssignments( + [layers[0], { ...layers[1], palette: layers[0].palette, splitAccessor: undefined }], + data, + formatFactory + ); + // two y accessors, with 3 splitted series for the first layer, 2 non splitted y accessors for the second layer + expect(assignments.palette1.totalSeriesCount).toEqual(2 * 3 + 2); + expect(assignments.palette2).toBeUndefined(); + }); + + it('should format non-primitive values and count them correctly', () => { + const complexObject = { aProp: 123 }; + const formatMock = jest.fn((x) => 'formatted'); + const assignments = getColorAssignments( + layers, + { + ...data, + tables: { + ...data.tables, + '1': { ...data.tables['1'], rows: [{ split1: complexObject }, { split1: 'abc' }] }, + }, + }, + (() => + ({ + convert: formatMock, + } as unknown)) as FormatFactory + ); + expect(assignments.palette1.totalSeriesCount).toEqual(2 * 2); + expect(assignments.palette2.totalSeriesCount).toEqual(2 * 3); + expect(formatMock).toHaveBeenCalledWith(complexObject); + }); + }); + + describe('getRank', () => { + it('should return the correct rank for a series key', () => { + const assignments = getColorAssignments(layers, data, formatFactory); + // 3 series in front of 2/y2 - 1/y1, 1/y2 and 2/y1 + expect(assignments.palette1.getRank(layers[0], '2', 'y2')).toEqual(3); + // 1 series in front of 1/y4 - 1/y3 + expect(assignments.palette2.getRank(layers[1], '1', 'y4')).toEqual(1); + }); + + it('should return the correct rank for a series key spanning multiple layers', () => { + const newLayers = [layers[0], { ...layers[1], palette: layers[0].palette }]; + const assignments = getColorAssignments(newLayers, data, formatFactory); + // 3 series in front of 2/y2 - 1/y1, 1/y2 and 2/y1 + expect(assignments.palette1.getRank(newLayers[0], '2', 'y2')).toEqual(3); + // 2 series in front for the current layer (1/y3, 1/y4), plus all 6 series from the first layer + expect(assignments.palette1.getRank(newLayers[1], '2', 'y3')).toEqual(8); + }); + + it('should return the correct rank for a series without a split', () => { + const newLayers = [ + layers[0], + { ...layers[1], palette: layers[0].palette, splitAccessor: undefined }, + ]; + const assignments = getColorAssignments(newLayers, data, formatFactory); + // 3 series in front of 2/y2 - 1/y1, 1/y2 and 2/y1 + expect(assignments.palette1.getRank(newLayers[0], '2', 'y2')).toEqual(3); + // 1 series in front for the current layer (y3), plus all 6 series from the first layer + expect(assignments.palette1.getRank(newLayers[1], 'Metric y4', 'y4')).toEqual(7); + }); + + it('should return the correct rank for a series with a non-primitive value', () => { + const assignments = getColorAssignments( + layers, + { + ...data, + tables: { + ...data.tables, + '1': { ...data.tables['1'], rows: [{ split1: 'abc' }, { split1: { aProp: 123 } }] }, + }, + }, + (() => + ({ + convert: () => 'formatted', + } as unknown)) as FormatFactory + ); + // 3 series in front of (complex object)/y1 - abc/y1, abc/y2 + expect(assignments.palette1.getRank(layers[0], 'formatted', 'y1')).toEqual(2); + }); + }); +}); diff --git a/x-pack/plugins/lens/public/xy_visualization/color_assignment.ts b/x-pack/plugins/lens/public/xy_visualization/color_assignment.ts new file mode 100644 index 0000000000000..5f72dd1b0453b --- /dev/null +++ b/x-pack/plugins/lens/public/xy_visualization/color_assignment.ts @@ -0,0 +1,71 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { uniq, mapValues } from 'lodash'; +import { FormatFactory, LensMultiTable } from '../types'; +import { LayerArgs, LayerConfig } from './types'; + +const isPrimitive = (value: unknown): boolean => value != null && typeof value !== 'object'; + +export function getColorAssignments( + layers: LayerArgs[], + data: LensMultiTable, + formatFactory: FormatFactory +) { + const layersPerPalette: Record = {}; + + layers.forEach((layer) => { + const palette = layer.palette?.name || 'palette'; + if (!layersPerPalette[palette]) { + layersPerPalette[palette] = []; + } + layersPerPalette[palette].push(layer); + }); + + return mapValues(layersPerPalette, (paletteLayers) => { + const seriesPerLayer = paletteLayers.map((layer, layerIndex) => { + if (!layer.splitAccessor) { + return { numberOfSeries: layer.accessors.length, splits: [] }; + } + const splitAccessor = layer.splitAccessor; + const column = data.tables[layer.layerId].columns.find(({ id }) => id === splitAccessor)!; + const splits = uniq( + data.tables[layer.layerId].rows.map((row) => { + let value = row[splitAccessor]; + if (value && !isPrimitive(value)) { + value = formatFactory(column.meta.params).convert(value); + } else { + value = String(value); + } + return value; + }) + ); + return { numberOfSeries: (splits.length || 1) * layer.accessors.length, splits }; + }); + const totalSeriesCount = seriesPerLayer.reduce( + (sum, perLayer) => sum + perLayer.numberOfSeries, + 0 + ); + return { + totalSeriesCount, + getRank(layer: LayerArgs, seriesKey: string, yAccessor: string) { + const layerIndex = paletteLayers.indexOf(layer); + const currentSeriesPerLayer = seriesPerLayer[layerIndex]; + return ( + (layerIndex === 0 + ? 0 + : seriesPerLayer + .slice(0, layerIndex) + .reduce((sum, perLayer) => sum + perLayer.numberOfSeries, 0)) + + (layer.splitAccessor + ? currentSeriesPerLayer.splits.indexOf(seriesKey) * layer.accessors.length + : 0) + + layer.accessors.indexOf(yAccessor) + ); + }, + }; + }); +} diff --git a/x-pack/plugins/lens/public/xy_visualization/expression.test.tsx b/x-pack/plugins/lens/public/xy_visualization/expression.test.tsx index 9e937399a7969..6c9669dc239ea 100644 --- a/x-pack/plugins/lens/public/xy_visualization/expression.test.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/expression.test.tsx @@ -17,6 +17,7 @@ import { SeriesNameFn, Fit, } from '@elastic/charts'; +import { PaletteOutput } from 'src/plugins/charts/public'; import { xyChart, XYChart } from './expression'; import { LensMultiTable } from '../types'; import { Datatable, DatatableRow } from '../../../../../src/plugins/expressions/public'; @@ -41,6 +42,13 @@ const onClickValue = jest.fn(); const onSelectRange = jest.fn(); const chartsThemeService = chartPluginMock.createSetupContract().theme; +const paletteService = chartPluginMock.createPaletteRegistry(); + +const mockPaletteOutput: PaletteOutput = { + type: 'palette', + name: 'mock', + params: {}, +}; const dateHistogramData: LensMultiTable = { type: 'lens_multitable', @@ -195,6 +203,7 @@ const dateHistogramLayer: LayerArgs = { splitAccessor: 'splitAccessorId', seriesType: 'bar_stacked', accessors: ['yAccessorId'], + palette: mockPaletteOutput, }; const createSampleDatatableWithRows = (rows: DatatableRow[]): Datatable => ({ @@ -235,6 +244,7 @@ const sampleLayer: LayerArgs = { xScaleType: 'ordinal', yScaleType: 'linear', isHistogram: false, + palette: mockPaletteOutput, }; const createArgsWithLayers = (layers: LayerArgs[] = [sampleLayer]): XYArgs => ({ @@ -309,6 +319,7 @@ describe('xy_expression', () => { xScaleType: 'linear', yScaleType: 'linear', isHistogram: false, + palette: mockPaletteOutput, }; const result = layerConfig.fn(null, args, createMockExecutionContext()); @@ -412,6 +423,7 @@ describe('xy_expression', () => { formatFactory={getFormatSpy} timeZone="UTC" chartsThemeService={chartsThemeService} + paletteService={paletteService} histogramBarTarget={50} onClickValue={onClickValue} onSelectRange={onSelectRange} @@ -435,6 +447,7 @@ describe('xy_expression', () => { formatFactory={getFormatSpy} timeZone="UTC" chartsThemeService={chartsThemeService} + paletteService={paletteService} histogramBarTarget={50} onClickValue={onClickValue} onSelectRange={onSelectRange} @@ -457,6 +470,7 @@ describe('xy_expression', () => { xScaleType: 'time', yScaleType: 'linear', isHistogram: false, + palette: mockPaletteOutput, }; const multiLayerArgs = createArgsWithLayers([ timeSampleLayer, @@ -486,6 +500,7 @@ describe('xy_expression', () => { formatFactory={getFormatSpy} timeZone="UTC" chartsThemeService={chartsThemeService} + paletteService={paletteService} histogramBarTarget={50} onClickValue={onClickValue} onSelectRange={onSelectRange} @@ -522,6 +537,7 @@ describe('xy_expression', () => { formatFactory={getFormatSpy} timeZone="UTC" chartsThemeService={chartsThemeService} + paletteService={paletteService} histogramBarTarget={50} onClickValue={onClickValue} onSelectRange={onSelectRange} @@ -559,6 +575,7 @@ describe('xy_expression', () => { formatFactory={getFormatSpy} timeZone="UTC" chartsThemeService={chartsThemeService} + paletteService={paletteService} histogramBarTarget={50} onClickValue={onClickValue} onSelectRange={onSelectRange} @@ -597,6 +614,7 @@ describe('xy_expression', () => { formatFactory={getFormatSpy} timeZone="UTC" chartsThemeService={chartsThemeService} + paletteService={paletteService} histogramBarTarget={50} onClickValue={onClickValue} onSelectRange={onSelectRange} @@ -642,6 +660,7 @@ describe('xy_expression', () => { formatFactory={getFormatSpy} timeZone="UTC" chartsThemeService={chartsThemeService} + paletteService={paletteService} histogramBarTarget={50} onClickValue={onClickValue} onSelectRange={onSelectRange} @@ -677,6 +696,7 @@ describe('xy_expression', () => { formatFactory={getFormatSpy} timeZone="UTC" chartsThemeService={chartsThemeService} + paletteService={paletteService} histogramBarTarget={50} onClickValue={onClickValue} onSelectRange={onSelectRange} @@ -694,6 +714,7 @@ describe('xy_expression', () => { formatFactory={getFormatSpy} timeZone="UTC" chartsThemeService={chartsThemeService} + paletteService={paletteService} histogramBarTarget={50} onClickValue={onClickValue} onSelectRange={onSelectRange} @@ -714,6 +735,7 @@ describe('xy_expression', () => { formatFactory={getFormatSpy} timeZone="UTC" chartsThemeService={chartsThemeService} + paletteService={paletteService} histogramBarTarget={50} onClickValue={onClickValue} onSelectRange={onSelectRange} @@ -734,6 +756,7 @@ describe('xy_expression', () => { formatFactory={getFormatSpy} timeZone="UTC" chartsThemeService={chartsThemeService} + paletteService={paletteService} histogramBarTarget={50} onClickValue={onClickValue} onSelectRange={onSelectRange} @@ -759,6 +782,7 @@ describe('xy_expression', () => { formatFactory={getFormatSpy} timeZone="UTC" chartsThemeService={chartsThemeService} + paletteService={paletteService} histogramBarTarget={50} onClickValue={onClickValue} onSelectRange={onSelectRange} @@ -782,6 +806,7 @@ describe('xy_expression', () => { formatFactory={getFormatSpy} timeZone="UTC" chartsThemeService={chartsThemeService} + paletteService={paletteService} histogramBarTarget={50} onClickValue={onClickValue} onSelectRange={onSelectRange} @@ -810,6 +835,7 @@ describe('xy_expression', () => { isHistogram: true, seriesType: 'bar_stacked', accessors: ['yAccessorId'], + palette: mockPaletteOutput, }; const numberHistogramData: LensMultiTable = { @@ -865,6 +891,7 @@ describe('xy_expression', () => { formatFactory={getFormatSpy} timeZone="UTC" chartsThemeService={chartsThemeService} + paletteService={paletteService} histogramBarTarget={50} onClickValue={onClickValue} onSelectRange={onSelectRange} @@ -909,12 +936,14 @@ describe('xy_expression', () => { splitAccessor: 'b', accessors: ['d'], columnToLabel: '{"a": "Label A", "b": "Label B", "d": "Label D"}', + palette: mockPaletteOutput, }, ], }} formatFactory={getFormatSpy} timeZone="UTC" chartsThemeService={chartsThemeService} + paletteService={paletteService} histogramBarTarget={50} onClickValue={onClickValue} onSelectRange={onSelectRange} @@ -952,6 +981,7 @@ describe('xy_expression', () => { formatFactory={getFormatSpy} timeZone="UTC" chartsThemeService={chartsThemeService} + paletteService={paletteService} histogramBarTarget={50} onClickValue={onClickValue} onSelectRange={onSelectRange} @@ -972,6 +1002,7 @@ describe('xy_expression', () => { formatFactory={getFormatSpy} timeZone="UTC" chartsThemeService={chartsThemeService} + paletteService={paletteService} histogramBarTarget={50} onClickValue={onClickValue} onSelectRange={onSelectRange} @@ -995,6 +1026,7 @@ describe('xy_expression', () => { formatFactory={getFormatSpy} timeZone="UTC" chartsThemeService={chartsThemeService} + paletteService={paletteService} histogramBarTarget={50} onClickValue={onClickValue} onSelectRange={onSelectRange} @@ -1027,6 +1059,7 @@ describe('xy_expression', () => { formatFactory={getFormatSpy} timeZone="UTC" chartsThemeService={chartsThemeService} + paletteService={paletteService} histogramBarTarget={50} onClickValue={onClickValue} onSelectRange={onSelectRange} @@ -1046,6 +1079,7 @@ describe('xy_expression', () => { formatFactory={getFormatSpy} timeZone="CEST" chartsThemeService={chartsThemeService} + paletteService={paletteService} histogramBarTarget={50} onClickValue={onClickValue} onSelectRange={onSelectRange} @@ -1071,6 +1105,7 @@ describe('xy_expression', () => { formatFactory={getFormatSpy} timeZone="UTC" chartsThemeService={chartsThemeService} + paletteService={paletteService} histogramBarTarget={50} onClickValue={onClickValue} onSelectRange={onSelectRange} @@ -1090,6 +1125,7 @@ describe('xy_expression', () => { formatFactory={getFormatSpy} timeZone="UTC" chartsThemeService={chartsThemeService} + paletteService={paletteService} histogramBarTarget={50} onClickValue={onClickValue} onSelectRange={onSelectRange} @@ -1112,6 +1148,7 @@ describe('xy_expression', () => { formatFactory={getFormatSpy} timeZone="UTC" chartsThemeService={chartsThemeService} + paletteService={paletteService} histogramBarTarget={50} onClickValue={onClickValue} onSelectRange={onSelectRange} @@ -1139,6 +1176,7 @@ describe('xy_expression', () => { formatFactory={getFormatSpy} timeZone="UTC" chartsThemeService={chartsThemeService} + paletteService={paletteService} histogramBarTarget={50} onClickValue={onClickValue} onSelectRange={onSelectRange} @@ -1160,6 +1198,7 @@ describe('xy_expression', () => { formatFactory={getFormatSpy} timeZone="UTC" chartsThemeService={chartsThemeService} + paletteService={paletteService} histogramBarTarget={50} onClickValue={onClickValue} onSelectRange={onSelectRange} @@ -1298,9 +1337,24 @@ describe('xy_expression', () => { } as XYArgs; const component = getRenderedComponent(dataWithoutFormats, newArgs); - expect((component.find(LineSeries).at(0).prop('color') as Function)!()).toEqual('#550000'); - expect((component.find(LineSeries).at(1).prop('color') as Function)!()).toEqual('#FFFF00'); - expect((component.find(LineSeries).at(2).prop('color') as Function)!()).toEqual('#FEECDF'); + expect( + (component.find(LineSeries).at(0).prop('color') as Function)!({ + yAccessor: 'a', + seriesKeys: ['a'], + }) + ).toEqual('#550000'); + expect( + (component.find(LineSeries).at(1).prop('color') as Function)!({ + yAccessor: 'b', + seriesKeys: ['b'], + }) + ).toEqual('#FFFF00'); + expect( + (component.find(LineSeries).at(2).prop('color') as Function)!({ + yAccessor: 'c', + seriesKeys: ['c'], + }) + ).toEqual('#FEECDF'); }); test('color is not applied to chart when splitAccessor is defined or when yConfig is not configured', () => { const args = createArgsWithLayers(); @@ -1326,8 +1380,18 @@ describe('xy_expression', () => { } as XYArgs; const component = getRenderedComponent(dataWithoutFormats, newArgs); - expect((component.find(LineSeries).at(0).prop('color') as Function)!()).toEqual(null); - expect((component.find(LineSeries).at(1).prop('color') as Function)!()).toEqual(null); + expect( + (component.find(LineSeries).at(0).prop('color') as Function)!({ + yAccessor: 'a', + seriesKeys: ['a'], + }) + ).toEqual('black'); + expect( + (component.find(LineSeries).at(1).prop('color') as Function)!({ + yAccessor: 'c', + seriesKeys: ['c'], + }) + ).toEqual('black'); }); }); @@ -1535,6 +1599,7 @@ describe('xy_expression', () => { formatFactory={getFormatSpy} timeZone="UTC" chartsThemeService={chartsThemeService} + paletteService={paletteService} histogramBarTarget={50} onClickValue={onClickValue} onSelectRange={onSelectRange} @@ -1554,6 +1619,7 @@ describe('xy_expression', () => { formatFactory={getFormatSpy} timeZone="UTC" chartsThemeService={chartsThemeService} + paletteService={paletteService} histogramBarTarget={50} onClickValue={onClickValue} onSelectRange={onSelectRange} @@ -1573,6 +1639,7 @@ describe('xy_expression', () => { formatFactory={getFormatSpy} timeZone="UTC" chartsThemeService={chartsThemeService} + paletteService={paletteService} histogramBarTarget={50} onClickValue={onClickValue} onSelectRange={onSelectRange} @@ -1591,6 +1658,7 @@ describe('xy_expression', () => { args={{ ...args, layers: [{ ...args.layers[0], accessors: ['a'] }] }} formatFactory={getFormatSpy} chartsThemeService={chartsThemeService} + paletteService={paletteService} histogramBarTarget={50} timeZone="UTC" onClickValue={onClickValue} @@ -1613,6 +1681,7 @@ describe('xy_expression', () => { formatFactory={getFormatSpy} timeZone="UTC" chartsThemeService={chartsThemeService} + paletteService={paletteService} histogramBarTarget={50} onClickValue={onClickValue} onSelectRange={onSelectRange} @@ -1647,6 +1716,7 @@ describe('xy_expression', () => { formatFactory={getFormatSpy} timeZone="UTC" chartsThemeService={chartsThemeService} + paletteService={paletteService} histogramBarTarget={50} onClickValue={onClickValue} onSelectRange={onSelectRange} @@ -1679,6 +1749,7 @@ describe('xy_expression', () => { formatFactory={getFormatSpy} timeZone="UTC" chartsThemeService={chartsThemeService} + paletteService={paletteService} histogramBarTarget={50} onClickValue={onClickValue} onSelectRange={onSelectRange} @@ -1711,6 +1782,7 @@ describe('xy_expression', () => { formatFactory={getFormatSpy} timeZone="UTC" chartsThemeService={chartsThemeService} + paletteService={paletteService} histogramBarTarget={50} onClickValue={onClickValue} onSelectRange={onSelectRange} @@ -1743,6 +1815,7 @@ describe('xy_expression', () => { formatFactory={getFormatSpy} timeZone="UTC" chartsThemeService={chartsThemeService} + paletteService={paletteService} histogramBarTarget={50} onClickValue={onClickValue} onSelectRange={onSelectRange} @@ -1817,6 +1890,7 @@ describe('xy_expression', () => { xScaleType: 'ordinal', yScaleType: 'linear', isHistogram: false, + palette: mockPaletteOutput, }, { layerId: 'second', @@ -1828,6 +1902,7 @@ describe('xy_expression', () => { xScaleType: 'ordinal', yScaleType: 'linear', isHistogram: false, + palette: mockPaletteOutput, }, ], }; @@ -1839,6 +1914,7 @@ describe('xy_expression', () => { formatFactory={getFormatSpy} timeZone="UTC" chartsThemeService={chartsThemeService} + paletteService={paletteService} histogramBarTarget={50} onClickValue={onClickValue} onSelectRange={onSelectRange} @@ -1899,6 +1975,7 @@ describe('xy_expression', () => { xScaleType: 'ordinal', yScaleType: 'linear', isHistogram: false, + palette: mockPaletteOutput, }, ], }; @@ -1910,6 +1987,7 @@ describe('xy_expression', () => { formatFactory={getFormatSpy} timeZone="UTC" chartsThemeService={chartsThemeService} + paletteService={paletteService} histogramBarTarget={50} onClickValue={onClickValue} onSelectRange={onSelectRange} @@ -1968,6 +2046,7 @@ describe('xy_expression', () => { xScaleType: 'ordinal', yScaleType: 'linear', isHistogram: false, + palette: mockPaletteOutput, }, ], }; @@ -1979,6 +2058,7 @@ describe('xy_expression', () => { formatFactory={getFormatSpy} timeZone="UTC" chartsThemeService={chartsThemeService} + paletteService={paletteService} histogramBarTarget={50} onClickValue={onClickValue} onSelectRange={onSelectRange} @@ -2002,6 +2082,7 @@ describe('xy_expression', () => { formatFactory={getFormatSpy} timeZone="UTC" chartsThemeService={chartsThemeService} + paletteService={paletteService} histogramBarTarget={50} onClickValue={onClickValue} onSelectRange={onSelectRange} @@ -2024,6 +2105,7 @@ describe('xy_expression', () => { formatFactory={getFormatSpy} timeZone="UTC" chartsThemeService={chartsThemeService} + paletteService={paletteService} histogramBarTarget={50} onClickValue={onClickValue} onSelectRange={onSelectRange} @@ -2046,6 +2128,7 @@ describe('xy_expression', () => { formatFactory={getFormatSpy} timeZone="UTC" chartsThemeService={chartsThemeService} + paletteService={paletteService} histogramBarTarget={50} onClickValue={onClickValue} onSelectRange={onSelectRange} @@ -2080,6 +2163,7 @@ describe('xy_expression', () => { formatFactory={getFormatSpy} timeZone="UTC" chartsThemeService={chartsThemeService} + paletteService={paletteService} histogramBarTarget={50} onClickValue={onClickValue} onSelectRange={onSelectRange} @@ -2106,6 +2190,7 @@ describe('xy_expression', () => { formatFactory={getFormatSpy} timeZone="UTC" chartsThemeService={chartsThemeService} + paletteService={paletteService} histogramBarTarget={50} onClickValue={onClickValue} onSelectRange={onSelectRange} @@ -2127,6 +2212,7 @@ describe('xy_expression', () => { formatFactory={getFormatSpy} timeZone="UTC" chartsThemeService={chartsThemeService} + paletteService={paletteService} histogramBarTarget={50} onClickValue={onClickValue} onSelectRange={onSelectRange} @@ -2153,6 +2239,7 @@ describe('xy_expression', () => { formatFactory={getFormatSpy} timeZone="UTC" chartsThemeService={chartsThemeService} + paletteService={paletteService} histogramBarTarget={50} onClickValue={onClickValue} onSelectRange={onSelectRange} @@ -2185,6 +2272,7 @@ describe('xy_expression', () => { formatFactory={getFormatSpy} timeZone="UTC" chartsThemeService={chartsThemeService} + paletteService={paletteService} histogramBarTarget={50} onClickValue={onClickValue} onSelectRange={onSelectRange} diff --git a/x-pack/plugins/lens/public/xy_visualization/expression.tsx b/x-pack/plugins/lens/public/xy_visualization/expression.tsx index 4a2c13e1e3520..877ddd3c0f27d 100644 --- a/x-pack/plugins/lens/public/xy_visualization/expression.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/expression.tsx @@ -42,11 +42,16 @@ import { XYArgs, SeriesType, visualizationTypes } from './types'; import { VisualizationContainer } from '../visualization_container'; import { isHorizontalChart, getSeriesColor } from './state_helpers'; import { parseInterval } from '../../../../../src/plugins/data/common'; -import { ChartsPluginSetup } from '../../../../../src/plugins/charts/public'; +import { + ChartsPluginSetup, + PaletteRegistry, + SeriesLayer, +} from '../../../../../src/plugins/charts/public'; import { EmptyPlaceholder } from '../shared_components'; import { desanitizeFilterContext } from '../utils'; import { fittingFunctionDefinitions, getFitOptions } from './fitting_functions'; import { getAxesConfiguration } from './axes_configuration'; +import { getColorAssignments } from './color_assignment'; type InferPropType = T extends React.FunctionComponent ? P : T; type SeriesSpec = InferPropType & @@ -66,6 +71,7 @@ export interface XYRender { type XYChartRenderProps = XYChartProps & { chartsThemeService: ChartsPluginSetup['theme']; + paletteService: PaletteRegistry; formatFactory: FormatFactory; timeZone: string; histogramBarTarget: number; @@ -165,6 +171,7 @@ export const xyChart: ExpressionFunctionDefinition< export const getXyChartRenderer = (dependencies: { formatFactory: Promise; chartsThemeService: ChartsPluginSetup['theme']; + paletteService: PaletteRegistry; histogramBarTarget: number; timeZone: string; }): ExpressionRenderDefinition => ({ @@ -194,6 +201,7 @@ export const getXyChartRenderer = (dependencies: { {...config} formatFactory={formatFactory} chartsThemeService={dependencies.chartsThemeService} + paletteService={dependencies.paletteService} timeZone={dependencies.timeZone} histogramBarTarget={dependencies.histogramBarTarget} onClickValue={onClickValue} @@ -241,6 +249,7 @@ export function XYChart({ formatFactory, timeZone, chartsThemeService, + paletteService, histogramBarTarget, onClickValue, onSelectRange, @@ -387,6 +396,8 @@ export function XYChart({ return style; }; + const colorAssignments = getColorAssignments(args.layers, data, formatFactory); + return ( = columnToLabel ? JSON.parse(columnToLabel) @@ -610,7 +622,33 @@ export function XYChart({ data: rows, xScaleType: xAccessor ? xScaleType : 'ordinal', yScaleType, - color: () => getSeriesColor(layer, accessor), + color: ({ yAccessor, seriesKeys }) => { + const overwriteColor = getSeriesColor(layer, accessor); + if (overwriteColor !== null) { + return overwriteColor; + } + const colorAssignment = colorAssignments[palette.name]; + const seriesLayers: SeriesLayer[] = [ + { + name: splitAccessor ? String(seriesKeys[0]) : columnToLabelMap[seriesKeys[0]], + totalSeriesAtDepth: colorAssignment.totalSeriesCount, + rankAtDepth: colorAssignment.getRank( + layer, + String(seriesKeys[0]), + String(yAccessor) + ), + }, + ]; + return paletteService.get(palette.name).getColor( + seriesLayers, + { + maxDepth: 1, + behindText: false, + totalSeries: colorAssignment.totalSeriesCount, + }, + palette.params + ); + }, groupId: yAxesConfiguration.find((axisConfiguration) => axisConfiguration.series.find((currentSeries) => currentSeries.accessor === accessor) )?.groupId, diff --git a/x-pack/plugins/lens/public/xy_visualization/index.ts b/x-pack/plugins/lens/public/xy_visualization/index.ts index 259267236ec49..4891a51b3124b 100644 --- a/x-pack/plugins/lens/public/xy_visualization/index.ts +++ b/x-pack/plugins/lens/public/xy_visualization/index.ts @@ -44,8 +44,9 @@ export class XyVisualization { layerConfig, xyChart, getXyChartRenderer, - xyVisualization, + getXyVisualization, } = await import('../async_services'); + const palettes = await charts.palettes.getPalettes(); expressions.registerFunction(() => legendConfig); expressions.registerFunction(() => yAxisConfig); expressions.registerFunction(() => tickLabelsConfig); @@ -58,11 +59,12 @@ export class XyVisualization { getXyChartRenderer({ formatFactory, chartsThemeService: charts.theme, + paletteService: palettes, timeZone: getTimeZone(core.uiSettings), histogramBarTarget: core.uiSettings.get(UI_SETTINGS.HISTOGRAM_BAR_TARGET), }) ); - return xyVisualization; + return getXyVisualization({ paletteService: palettes }); }); } } diff --git a/x-pack/plugins/lens/public/xy_visualization/to_expression.test.ts b/x-pack/plugins/lens/public/xy_visualization/to_expression.test.ts index d09ba01b32c66..6148824bfec21 100644 --- a/x-pack/plugins/lens/public/xy_visualization/to_expression.test.ts +++ b/x-pack/plugins/lens/public/xy_visualization/to_expression.test.ts @@ -6,11 +6,15 @@ import { Ast } from '@kbn/interpreter/target/common'; import { Position } from '@elastic/charts'; -import { xyVisualization } from './xy_visualization'; +import { chartPluginMock } from '../../../../../src/plugins/charts/public/mocks'; +import { getXyVisualization } from './xy_visualization'; import { Operation } from '../types'; import { createMockDatasource, createMockFramePublicAPI } from '../editor_frame_service/mocks'; describe('#toExpression', () => { + const xyVisualization = getXyVisualization({ + paletteService: chartPluginMock.createPaletteRegistry(), + }); let mockDatasource: ReturnType; let frame: ReturnType; diff --git a/x-pack/plugins/lens/public/xy_visualization/to_expression.ts b/x-pack/plugins/lens/public/xy_visualization/to_expression.ts index 701f15e8ef159..904d1541a85ec 100644 --- a/x-pack/plugins/lens/public/xy_visualization/to_expression.ts +++ b/x-pack/plugins/lens/public/xy_visualization/to_expression.ts @@ -6,6 +6,7 @@ import { Ast } from '@kbn/interpreter/common'; import { ScaleType } from '@elastic/charts'; +import { PaletteRegistry } from 'src/plugins/charts/public'; import { State, LayerConfig } from './types'; import { OperationMetadata, DatasourcePublicAPI } from '../types'; @@ -25,6 +26,7 @@ export const getSortedAccessors = (datasource: DatasourcePublicAPI, layer: Layer export const toExpression = ( state: State, datasourceLayers: Record, + paletteService: PaletteRegistry, attributes: Partial<{ title: string; description: string }> = {} ): Ast | null => { if (!state || !state.layers.length) { @@ -41,12 +43,13 @@ export const toExpression = ( }); }); - return buildExpression(state, metadata, datasourceLayers, attributes); + return buildExpression(state, metadata, datasourceLayers, paletteService, attributes); }; export function toPreviewExpression( state: State, - datasourceLayers: Record + datasourceLayers: Record, + paletteService: PaletteRegistry ) { return toExpression( { @@ -58,7 +61,9 @@ export function toPreviewExpression( isVisible: false, }, }, - datasourceLayers + datasourceLayers, + paletteService, + {} ); } @@ -91,7 +96,8 @@ export function getScaleType(metadata: OperationMetadata | null, defaultScale: S export const buildExpression = ( state: State, metadata: Record>, - datasourceLayers?: Record, + datasourceLayers: Record, + paletteService: PaletteRegistry, attributes: Partial<{ title: string; description: string }> = {} ): Ast | null => { const validLayers = state.layers @@ -194,17 +200,15 @@ export const buildExpression = ( layers: validLayers.map((layer) => { const columnToLabel: Record = {}; - if (datasourceLayers) { - const datasource = datasourceLayers[layer.layerId]; - layer.accessors - .concat(layer.splitAccessor ? [layer.splitAccessor] : []) - .forEach((accessor) => { - const operation = datasource.getOperationForColumnId(accessor); - if (operation?.label) { - columnToLabel[accessor] = operation.label; - } - }); - } + const datasource = datasourceLayers[layer.layerId]; + layer.accessors + .concat(layer.splitAccessor ? [layer.splitAccessor] : []) + .forEach((accessor) => { + const operation = datasource.getOperationForColumnId(accessor); + if (operation?.label) { + columnToLabel[accessor] = operation.label; + } + }); const xAxisOperation = datasourceLayers && @@ -256,6 +260,29 @@ export const buildExpression = ( seriesType: [layer.seriesType], accessors: layer.accessors, columnToLabel: [JSON.stringify(columnToLabel)], + ...(layer.palette + ? { + palette: [ + { + type: 'expression', + chain: [ + { + type: 'function', + function: 'theme', + arguments: { + variable: ['palette'], + default: [ + paletteService + .get(layer.palette.name) + .toExpression(layer.palette.params), + ], + }, + }, + ], + }, + ], + } + : {}), }, }, ], diff --git a/x-pack/plugins/lens/public/xy_visualization/types.ts b/x-pack/plugins/lens/public/xy_visualization/types.ts index abee787888787..d1e78aec57998 100644 --- a/x-pack/plugins/lens/public/xy_visualization/types.ts +++ b/x-pack/plugins/lens/public/xy_visualization/types.ts @@ -6,6 +6,7 @@ import { Position } from '@elastic/charts'; import { i18n } from '@kbn/i18n'; +import { PaletteOutput } from 'src/plugins/charts/public'; import { ArgumentType, ExpressionFunctionDefinition } from 'src/plugins/expressions/common'; import { LensIconChartArea } from '../assets/chart_area'; import { LensIconChartAreaStacked } from '../assets/chart_area_stacked'; @@ -335,6 +336,11 @@ export const layerConfig: ExpressionFunctionDefinition< types: ['string'], help: 'JSON key-value pairs of column ID to label', }, + palette: { + default: `{theme "palette" default={system_palette name="default"} }`, + help: '', + types: ['palette'], + }, }, fn: function fn(input: unknown, args: LayerArgs) { return { @@ -372,6 +378,7 @@ export interface LayerConfig { yConfig?: YConfig[]; seriesType: SeriesType; splitAccessor?: string; + palette?: PaletteOutput; } export type LayerArgs = LayerConfig & { @@ -379,6 +386,8 @@ export type LayerArgs = LayerConfig & { yScaleType: 'time' | 'linear' | 'log' | 'sqrt'; xScaleType: 'time' | 'linear' | 'ordinal'; isHistogram: boolean; + // palette will always be set on the expression + palette: PaletteOutput; }; // Arguments to XY chart expression, with computed properties diff --git a/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts b/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts index 3706611575c6b..4dde646ab64a5 100644 --- a/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts +++ b/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts @@ -4,12 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ -import { xyVisualization } from './visualization'; +import { getXyVisualization } from './visualization'; import { Position } from '@elastic/charts'; import { Operation } from '../types'; import { State, SeriesType } from './types'; import { createMockDatasource, createMockFramePublicAPI } from '../editor_frame_service/mocks'; import { LensIconChartBar } from '../assets/chart_bar'; +import { chartPluginMock } from '../../../../../src/plugins/charts/public/mocks'; function exampleState(): State { return { @@ -27,6 +28,10 @@ function exampleState(): State { }; } +const xyVisualization = getXyVisualization({ + paletteService: chartPluginMock.createPaletteRegistry(), +}); + describe('xy_visualization', () => { describe('#getDescription', () => { function mixedState(...types: SeriesType[]) { diff --git a/x-pack/plugins/lens/public/xy_visualization/visualization.tsx b/x-pack/plugins/lens/public/xy_visualization/visualization.tsx index 5c2eaa42c09c3..c41d8e977297b 100644 --- a/x-pack/plugins/lens/public/xy_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/visualization.tsx @@ -10,6 +10,7 @@ import { render } from 'react-dom'; import { Position } from '@elastic/charts'; import { I18nProvider } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; +import { PaletteRegistry } from 'src/plugins/charts/public'; import { getSuggestions } from './xy_suggestions'; import { LayerContextMenu, XyToolbar, DimensionEditor } from './xy_config_panel'; import { Visualization, OperationMetadata, VisualizationType } from '../types'; @@ -73,7 +74,11 @@ function getDescription(state?: State) { }; } -export const xyVisualization: Visualization = { +export const getXyVisualization = ({ + paletteService, +}: { + paletteService: PaletteRegistry; +}): Visualization => ({ id: 'lnsXY', visualizationTypes, @@ -210,11 +215,17 @@ export const xyVisualization: Visualization = { supportsMoreColumns: !layer.splitAccessor, dataTestSubj: 'lnsXY_splitDimensionPanel', required: layer.seriesType.includes('percentage'), + enableDimensionEditor: true, }, ], }; }, + getMainPalette: (state) => { + if (!state || state.layers.length === 0) return; + return state.layers[0].palette; + }, + setDimension({ prevState, layerId, columnId, groupId }) { const newLayer = prevState.layers.find((l) => l.layerId === layerId); if (!newLayer) { @@ -247,6 +258,8 @@ export const xyVisualization: Visualization = { delete newLayer.xAccessor; } else if (newLayer.splitAccessor === columnId) { delete newLayer.splitAccessor; + // as the palette is associated with the break down by dimension, remove it together with the dimension + delete newLayer.palette; } else if (newLayer.accessors.includes(columnId)) { newLayer.accessors = newLayer.accessors.filter((a) => a !== columnId); } @@ -293,9 +306,10 @@ export const xyVisualization: Visualization = { ); }, - toExpression, - toPreviewExpression, -}; + toExpression: (state, layers, attributes) => + toExpression(state, layers, paletteService, attributes), + toPreviewExpression: (state, layers) => toPreviewExpression(state, layers, paletteService), +}); function newLayerState(seriesType: SeriesType, layerId: string): LayerConfig { return { diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx index ee22ee51301df..97e42113fc180 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx @@ -24,8 +24,8 @@ import { } from '@elastic/eui'; import { VisualizationLayerWidgetProps, - VisualizationDimensionEditorProps, VisualizationToolbarProps, + VisualizationDimensionEditorProps, } from '../types'; import { State, SeriesType, visualizationTypes, YAxisMode, AxesSettingsConfig } from './types'; import { isHorizontalChart, isHorizontalSeries, getSeriesColor } from './state_helpers'; @@ -35,6 +35,7 @@ import { ToolbarPopover, LegendSettingsPopover } from '../shared_components'; import { AxisSettingsPopover } from './axis_settings_popover'; import { TooltipWrapper } from './tooltip_wrapper'; import { getAxesConfiguration } from './axes_configuration'; +import { PalettePicker } from '../shared_components'; type UnwrapArray = T extends Array ? P : T; type AxesSettingsConfigKeys = keyof AxesSettingsConfig; @@ -364,6 +365,20 @@ export function DimensionEditor(props: VisualizationDimensionEditorProps) layer.yConfig?.find((yAxisConfig) => yAxisConfig.forAccessor === accessor)?.axisMode) || 'auto'; + if (props.groupId === 'breakdown') { + return ( + <> + { + setState(updateLayer(state, { ...layer, palette: newPalette }, index)); + }} + /> + + ); + } + return ( <> diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.test.ts b/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.test.ts index 1ab00eef0593b..ac0039df966d4 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.test.ts +++ b/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.test.ts @@ -13,10 +13,16 @@ import { } from '../types'; import { State, XYState, visualizationTypes } from './types'; import { generateId } from '../id_generator'; -import { xyVisualization } from './xy_visualization'; +import { getXyVisualization } from './xy_visualization'; +import { chartPluginMock } from '../../../../../src/plugins/charts/public/mocks'; +import { PaletteOutput } from 'src/plugins/charts/public'; jest.mock('../id_generator'); +const xyVisualization = getXyVisualization({ + paletteService: chartPluginMock.createPaletteRegistry(), +}); + describe('xy_suggestions', () => { function numCol(columnId: string): TableSuggestionColumn { return { @@ -475,6 +481,38 @@ describe('xy_suggestions', () => { ); }); + test('includes passed in palette for split charts if specified', () => { + const mainPalette: PaletteOutput = { type: 'palette', name: 'mock' }; + const [suggestion] = getSuggestions({ + table: { + isMultiRow: true, + columns: [numCol('price'), numCol('quantity'), dateCol('date'), strCol('product')], + layerId: 'first', + changeType: 'unchanged', + }, + keptLayerIds: [], + mainPalette, + }); + + expect(suggestion.state.layers[0].palette).toEqual(mainPalette); + }); + + test('ignores passed in palette for non splitted charts', () => { + const mainPalette: PaletteOutput = { type: 'palette', name: 'mock' }; + const [suggestion] = getSuggestions({ + table: { + isMultiRow: true, + columns: [numCol('price'), dateCol('date')], + layerId: 'first', + changeType: 'unchanged', + }, + keptLayerIds: [], + mainPalette, + }); + + expect(suggestion.state.layers[0].palette).toEqual(undefined); + }); + test('hides reduced suggestions if there is a current state', () => { const [suggestion, ...rest] = getSuggestions({ table: { diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.ts b/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.ts index 47b97af3071ab..edb7c4ed52243 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.ts +++ b/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.ts @@ -7,6 +7,7 @@ import { i18n } from '@kbn/i18n'; import { partition } from 'lodash'; import { Position } from '@elastic/charts'; +import { PaletteOutput } from 'src/plugins/charts/public'; import { SuggestionRequest, VisualizationSuggestion, @@ -36,6 +37,7 @@ export function getSuggestions({ state, keptLayerIds, subVisualizationId, + mainPalette, }: SuggestionRequest): Array> { if ( // We only render line charts for multi-row queries. We require at least @@ -71,7 +73,8 @@ export function getSuggestions({ table, keptLayerIds, state, - subVisualizationId as SeriesType | undefined + subVisualizationId as SeriesType | undefined, + mainPalette ); if (suggestions && suggestions instanceof Array) { @@ -85,7 +88,8 @@ function getSuggestionForColumns( table: TableSuggestion, keptLayerIds: string[], currentState?: State, - seriesType?: SeriesType + seriesType?: SeriesType, + mainPalette?: PaletteOutput ): VisualizationSuggestion | Array> | undefined { const [buckets, values] = partition(table.columns, (col) => col.operation.isBucketed); @@ -101,6 +105,7 @@ function getSuggestionForColumns( tableLabel: table.label, keptLayerIds, requestedSeriesType: seriesType, + mainPalette, }); } else if (buckets.length === 0) { const [x, ...yValues] = prioritizeColumns(values); @@ -114,6 +119,7 @@ function getSuggestionForColumns( tableLabel: table.label, keptLayerIds, requestedSeriesType: seriesType, + mainPalette, }); } } @@ -198,6 +204,7 @@ function getSuggestionsForLayer({ tableLabel, keptLayerIds, requestedSeriesType, + mainPalette, }: { layerId: string; changeType: TableChangeType; @@ -208,6 +215,7 @@ function getSuggestionsForLayer({ tableLabel?: string; keptLayerIds: string[]; requestedSeriesType?: SeriesType; + mainPalette?: PaletteOutput; }): VisualizationSuggestion | Array> { const title = getSuggestionTitle(yValues, xValue, tableLabel); const seriesType: SeriesType = @@ -223,6 +231,8 @@ function getSuggestionsForLayer({ changeType, xValue, keptLayerIds, + // only use palette if there is a breakdown by dimension + mainPalette: splitBy ? mainPalette : undefined, }; // handles the simplest cases, acting as a chart switcher @@ -449,6 +459,7 @@ function buildSuggestion({ xValue, keptLayerIds, hide, + mainPalette, }: { currentState: XYState | undefined; seriesType: SeriesType; @@ -460,6 +471,7 @@ function buildSuggestion({ changeType: TableChangeType; keptLayerIds: string[]; hide?: boolean; + mainPalette?: PaletteOutput; }) { if (seriesType.includes('percentage') && xValue?.operation.scale === 'ordinal' && !splitBy) { splitBy = xValue; @@ -469,6 +481,7 @@ function buildSuggestion({ const accessors = yValues.map((col) => col.columnId); const newLayer = { ...existingLayer, + palette: mainPalette || ('palette' in existingLayer ? existingLayer.palette : undefined), layerId, seriesType, xAccessor: xValue?.columnId, diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 4eee3fbc37c92..e8a09f07402b1 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -5842,10 +5842,6 @@ "xpack.canvas.functions.metricHelpText": "ラベルの上に数字を表示します。", "xpack.canvas.functions.neq.args.valueHelpText": "{CONTEXT} と比較される値です。", "xpack.canvas.functions.neqHelpText": "{CONTEXT} が引数と等しくないかを戻します。", - "xpack.canvas.functions.palette.args.colorHelpText": "パレットの色です。{html} カラー名、{hex}、{hsl}、{hsla}、{rgb}、または {rgba} を受け付けます。", - "xpack.canvas.functions.palette.args.gradientHelpText": "サポートされている場合グラデーションパレットを作成しますか?", - "xpack.canvas.functions.palette.args.reverseHelpText": "パレットを反転させますか?", - "xpack.canvas.functions.paletteHelpText": "カラーパレットを作成します。", "xpack.canvas.functions.pie.args.fontHelpText": "表の {CSS} フォントプロパティです。例: {FONT_FAMILY} または {FONT_WEIGHT}。", "xpack.canvas.functions.pie.args.holeHelpText": "円グラフに穴をあけます、0~100 で円グラフの半径のパーセンテージを指定します。", "xpack.canvas.functions.pie.args.labelRadiusHelpText": "ラベルの円の半径として使用する、コンテナーの面積のパーセンテージです。", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index c1645fc381494..6e47f50bcdafa 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -5848,10 +5848,6 @@ "xpack.canvas.functions.metricHelpText": "在标签上显示数字。", "xpack.canvas.functions.neq.args.valueHelpText": "与 {CONTEXT} 比较的值。", "xpack.canvas.functions.neqHelpText": "返回 {CONTEXT} 是否不等于参数。", - "xpack.canvas.functions.palette.args.colorHelpText": "调色板颜色。接受 {html} 颜色名称、{hex}、{hsl}、{hsla}、{rgb} 或 {rgba}。", - "xpack.canvas.functions.palette.args.gradientHelpText": "受支持时提供渐变的调色板?", - "xpack.canvas.functions.palette.args.reverseHelpText": "反转调色板?", - "xpack.canvas.functions.paletteHelpText": "创建颜色调色板。", "xpack.canvas.functions.pie.args.fontHelpText": "标签的 {CSS} 字体属性。例如 {FONT_FAMILY} 或 {FONT_WEIGHT}。", "xpack.canvas.functions.pie.args.holeHelpText": "在饼图中绘制介于 `0` and `100`(饼图半径的百分比)之间的孔洞。", "xpack.canvas.functions.pie.args.labelRadiusHelpText": "要用作标签圆形半径的容器面积百分比。", diff --git a/x-pack/test/functional/apps/lens/colors.ts b/x-pack/test/functional/apps/lens/colors.ts new file mode 100644 index 0000000000000..b058e800eb088 --- /dev/null +++ b/x-pack/test/functional/apps/lens/colors.ts @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const PageObjects = getPageObjects(['visualize', 'lens', 'common']); + + describe('lens color palette tests', () => { + it('should allow to pick color palette in xy chart', async () => { + await PageObjects.visualize.navigateToNewVisualization(); + await PageObjects.visualize.clickVisType('lens'); + await PageObjects.lens.goToTimeRange(); + + await PageObjects.lens.configureDimension({ + dimension: 'lnsXY_xDimensionPanel > lns-empty-dimension', + operation: 'terms', + field: 'geo.src', + }); + + await PageObjects.lens.configureDimension({ + dimension: 'lnsXY_yDimensionPanel > lns-empty-dimension', + operation: 'avg', + field: 'bytes', + }); + + await PageObjects.lens.configureDimension({ + dimension: 'lnsXY_splitDimensionPanel > lns-empty-dimension', + operation: 'terms', + field: '@message.raw', + palette: 'negative', + keepOpen: true, + }); + + await PageObjects.lens.assertPalette('negative'); + }); + + it('should carry over palette to the pie chart', async () => { + await PageObjects.lens.switchToVisualization('donut'); + await PageObjects.lens.openDimensionEditor( + 'lnsPie_sliceByDimensionPanel > lns-dimensionTrigger' + ); + await PageObjects.lens.assertPalette('negative'); + }); + + it('should carry palette back to the bar chart', async () => { + await PageObjects.lens.switchToVisualization('bar'); + await PageObjects.lens.openDimensionEditor( + 'lnsXY_splitDimensionPanel > lns-dimensionTrigger' + ); + await PageObjects.lens.assertPalette('negative'); + }); + }); +} diff --git a/x-pack/test/functional/apps/lens/index.ts b/x-pack/test/functional/apps/lens/index.ts index d1ecf8fa0973a..e2a0430361149 100644 --- a/x-pack/test/functional/apps/lens/index.ts +++ b/x-pack/test/functional/apps/lens/index.ts @@ -30,6 +30,7 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./smokescreen')); loadTestFile(require.resolve('./dashboard')); loadTestFile(require.resolve('./persistent_context')); + loadTestFile(require.resolve('./colors')); loadTestFile(require.resolve('./lens_reporting')); // has to be last one in the suite because it overrides saved objects diff --git a/x-pack/test/functional/page_objects/lens_page.ts b/x-pack/test/functional/page_objects/lens_page.ts index 05818ae3bbf66..f33fbcf296786 100644 --- a/x-pack/test/functional/page_objects/lens_page.ts +++ b/x-pack/test/functional/page_objects/lens_page.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import expect from '@kbn/expect'; import { FtrProviderContext } from '../ftr_provider_context'; import { logWrapper } from './log_wrapper'; @@ -91,6 +92,7 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont field?: string; isPreviousIncompatible?: boolean; keepOpen?: boolean; + palette?: string; }, layerIndex = 0 ) { @@ -109,11 +111,26 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont await comboBox.setElement(target, opts.field); } + if (opts.palette) { + await testSubjects.click('lns-palettePicker'); + await find.clickByCssSelector(`#${opts.palette}`); + } + if (!opts.keepOpen) { this.closeDimensionEditor(); } }, + async assertPalette(palette: string) { + await retry.try(async () => { + await testSubjects.click('lns-palettePicker'); + const currentPalette = await ( + await find.byCssSelector('[aria-selected=true]') + ).getAttribute('id'); + expect(currentPalette).to.equal(palette); + }); + }, + /** * Open the specified dimension. *