From 9de135c0bd5a04686de8b2b209fe4c9617a154b5 Mon Sep 17 00:00:00 2001 From: Ezra Odio <97199317+ezraodio1@users.noreply.github.com> Date: Thu, 8 Aug 2024 13:08:49 -0400 Subject: [PATCH] Add option to choose color scheme for charts (#7062) --- .../integration/visualizations/chart_spec.js | 70 +++++++++++++----- viz-lib/src/visualizations/ColorPalette.ts | 73 ++++++++++++++++++- .../chart/Editor/DefaultColorsSettings.tsx | 29 ++++++-- .../chart/Editor/GeneralSettings.tsx | 2 +- .../chart/Editor/PieColorsSettings.tsx | 27 ++++++- .../src/visualizations/chart/getOptions.ts | 1 + .../prepareData/pie/custom-tooltip.json | 6 +- .../fixtures/prepareData/pie/default.json | 6 +- .../prepareData/pie/without-labels.json | 6 +- .../fixtures/prepareData/pie/without-x.json | 6 +- .../chart/plotly/prepareDefaultData.ts | 21 ++++-- .../chart/plotly/preparePieData.ts | 33 +++++++-- 12 files changed, 225 insertions(+), 55 deletions(-) diff --git a/client/cypress/integration/visualizations/chart_spec.js b/client/cypress/integration/visualizations/chart_spec.js index 830023ce56..cb3795fdfc 100644 --- a/client/cypress/integration/visualizations/chart_spec.js +++ b/client/cypress/integration/visualizations/chart_spec.js @@ -26,33 +26,33 @@ const SQL = ` describe("Chart", () => { beforeEach(() => { cy.login(); - cy.createQuery({ name: "Chart Visualization", query: SQL }) - .its("id") - .as("queryId"); + cy.createQuery({ name: "Chart Visualization", query: SQL }).its("id").as("queryId"); }); - it("creates Bar charts", function() { + it("creates Bar charts", function () { cy.visit(`queries/${this.queryId}/source`); cy.getByTestId("ExecuteButton").click(); - const getBarChartAssertionFunction = (specificBarChartAssertionFn = () => {}) => () => { - // checks for TabbedEditor standard tabs - assertTabbedEditor(); + const getBarChartAssertionFunction = + (specificBarChartAssertionFn = () => {}) => + () => { + // checks for TabbedEditor standard tabs + assertTabbedEditor(); - // standard chart should be bar - cy.getByTestId("Chart.GlobalSeriesType").contains(".ant-select-selection-item", "Bar"); + // standard chart should be bar + cy.getByTestId("Chart.GlobalSeriesType").contains(".ant-select-selection-item", "Bar"); - // checks the plot canvas exists and is empty - assertPlotPreview("not.exist"); + // checks the plot canvas exists and is empty + assertPlotPreview("not.exist"); - // creates a chart and checks it is plotted - cy.getByTestId("Chart.ColumnMapping.x").selectAntdOption("Chart.ColumnMapping.x.stage"); - cy.getByTestId("Chart.ColumnMapping.y").selectAntdOption("Chart.ColumnMapping.y.value1"); - cy.getByTestId("Chart.ColumnMapping.y").selectAntdOption("Chart.ColumnMapping.y.value2"); - assertPlotPreview("exist"); + // creates a chart and checks it is plotted + cy.getByTestId("Chart.ColumnMapping.x").selectAntdOption("Chart.ColumnMapping.x.stage"); + cy.getByTestId("Chart.ColumnMapping.y").selectAntdOption("Chart.ColumnMapping.y.value1"); + cy.getByTestId("Chart.ColumnMapping.y").selectAntdOption("Chart.ColumnMapping.y.value2"); + assertPlotPreview("exist"); - specificBarChartAssertionFn(); - }; + specificBarChartAssertionFn(); + }; const chartTests = [ { @@ -95,8 +95,8 @@ describe("Chart", () => { const withDashboardWidgetsAssertionFn = (widgetGetters, dashboardUrl) => { cy.visit(dashboardUrl); - widgetGetters.forEach(widgetGetter => { - cy.get(`@${widgetGetter}`).then(widget => { + widgetGetters.forEach((widgetGetter) => { + cy.get(`@${widgetGetter}`).then((widget) => { cy.getByTestId(getWidgetTestId(widget)).within(() => { cy.get("g.points").should("exist"); }); @@ -107,4 +107,34 @@ describe("Chart", () => { createDashboardWithCharts("Bar chart visualizations", chartGetters, withDashboardWidgetsAssertionFn); cy.percySnapshot("Visualizations - Charts - Bar"); }); + it("colors Bar charts", function () { + cy.visit(`queries/${this.queryId}/source`); + cy.getByTestId("ExecuteButton").click(); + cy.getByTestId("NewVisualization").click(); + cy.getByTestId("Chart.ColumnMapping.x").selectAntdOption("Chart.ColumnMapping.x.stage"); + cy.getByTestId("Chart.ColumnMapping.y").selectAntdOption("Chart.ColumnMapping.y.value1"); + cy.getByTestId("VisualizationEditor.Tabs.Colors").click(); + cy.getByTestId("ColorScheme").click(); + cy.getByTestId("ColorOptionViridis").click(); + cy.getByTestId("ColorScheme").click(); + cy.getByTestId("ColorOptionTableau 10").click(); + cy.getByTestId("ColorScheme").click(); + cy.getByTestId("ColorOptionD3 Category 10").click(); + }); + it("colors Pie charts", function () { + cy.visit(`queries/${this.queryId}/source`); + cy.getByTestId("ExecuteButton").click(); + cy.getByTestId("NewVisualization").click(); + cy.getByTestId("Chart.GlobalSeriesType").click(); + cy.getByTestId("Chart.ChartType.pie").click(); + cy.getByTestId("Chart.ColumnMapping.x").selectAntdOption("Chart.ColumnMapping.x.stage"); + cy.getByTestId("Chart.ColumnMapping.y").selectAntdOption("Chart.ColumnMapping.y.value1"); + cy.getByTestId("VisualizationEditor.Tabs.Colors").click(); + cy.getByTestId("ColorScheme").click(); + cy.getByTestId("ColorOptionViridis").click(); + cy.getByTestId("ColorScheme").click(); + cy.getByTestId("ColorOptionTableau 10").click(); + cy.getByTestId("ColorScheme").click(); + cy.getByTestId("ColorOptionD3 Category 10").click(); + }); }); diff --git a/viz-lib/src/visualizations/ColorPalette.ts b/viz-lib/src/visualizations/ColorPalette.ts index 3663a68218..7d7706cdff 100644 --- a/viz-lib/src/visualizations/ColorPalette.ts +++ b/viz-lib/src/visualizations/ColorPalette.ts @@ -1,6 +1,6 @@ import { values } from "lodash"; -// The following colors will be used if you pick "Automatic" color +// Define color palettes export const BaseColors = { Blue: "#356AFF", Red: "#E92828", @@ -28,11 +28,78 @@ export const AdditionalColors = { "Pink 2": "#C63FA9", }; -export const ColorPaletteArray = values(BaseColors); +const Viridis = { + 1: '#440154', + 2: '#48186a', + 3: '#472d7b', + 4: '#424086', + 5: '#3b528b', + 6: '#33638d', + 7: '#2c728e', + 8: '#26828e', + 9: '#21918c', + 10: '#1fa088', + 11: '#28ae80', + 12: '#3fbc73', + 13: '#5ec962', + 14: '#84d44b', + 15: '#addc30', + 16: '#d8e219', + 17: '#fde725', +}; + +const Tableau = { + 1 : "#4e79a7", + 2 : "#f28e2c", + 3 : "#e15759", + 4 : "#76b7b2", + 5 : "#59a14f", + 6 : "#edc949", + 7 : "#af7aa1", + 8 : "#ff9da7", + 9 : "#9c755f", + 10 : "#bab0ab", +} -const ColorPalette = { +const D3Category10 = { + 1 : "#1f77b4", + 2 : "#ff7f0e", + 3 : "#2ca02c", + 4 : "#d62728", + 5 : "#9467bd", + 6 : "#8c564b", + 7 : "#e377c2", + 8 : "#7f7f7f", + 9 : "#bcbd22", + 10 : "#17becf", +} + +let ColorPalette = { ...BaseColors, ...AdditionalColors, }; +export const ColorPaletteArray = values(ColorPalette); + export default ColorPalette; + +export const AllColorPalettes = { + "Redash" : ColorPalette, + "Viridis" : Viridis, + "Tableau 10" : Tableau, + "D3 Category 10" : D3Category10, +} + +export const AllColorPaletteArrays = { + "Redash" : ColorPaletteArray, + "Viridis" : values(Viridis), + "Tableau 10" : values(Tableau), + "D3 Category 10" : values(D3Category10), +}; + +export const ColorPaletteTypes = { + "Redash" : 'discrete', + "Viridis" : 'continuous', + "Tableau 10" : 'discrete', + "D3 Category 10" : 'discrete', +} diff --git a/viz-lib/src/visualizations/chart/Editor/DefaultColorsSettings.tsx b/viz-lib/src/visualizations/chart/Editor/DefaultColorsSettings.tsx index 01286325f5..5d9dd5927e 100644 --- a/viz-lib/src/visualizations/chart/Editor/DefaultColorsSettings.tsx +++ b/viz-lib/src/visualizations/chart/Editor/DefaultColorsSettings.tsx @@ -3,16 +3,18 @@ import React, { useMemo, useCallback } from "react"; import Table from "antd/lib/table"; import ColorPicker from "@/components/ColorPicker"; import { EditorPropTypes } from "@/visualizations/prop-types"; -import ColorPalette from "@/visualizations/ColorPalette"; +import { AllColorPalettes } from "@/visualizations/ColorPalette"; import getChartData from "../getChartData"; +import { Section, Select } from "@/components/visualizations/editor"; export default function DefaultColorsSettings({ options, data, onOptionsChange }: any) { const colors = useMemo( () => ({ Automatic: null, - ...ColorPalette, + // @ts-expect-error ts-migrate(7053) FIXME: Element implicitly has an 'any' type because expre... Remove this comment to see the full error message + ...AllColorPalettes[options.color_scheme], }), - [] + [options.color_scheme] ); const series = useMemo( @@ -67,8 +69,25 @@ export default function DefaultColorsSettings({ options, data, onOptionsChange } }, ]; - // @ts-expect-error ts-migrate(2322) FIXME: Type 'boolean[]' is not assignable to type 'object... Remove this comment to see the full error message - return ; + return ( + + {/* @ts-expect-error ts-migrate(2745) FIXME: This JSX tag's 'children' prop expects type 'never... Remove this comment to see the full error message */} +
+ +
+ {/* @ts-expect-error ts-migrate(2322) FIXME: Type 'boolean[]' is not assignable to type 'object... Remove this comment to see the full error message */} +
+ + ) } DefaultColorsSettings.propTypes = EditorPropTypes; diff --git a/viz-lib/src/visualizations/chart/Editor/GeneralSettings.tsx b/viz-lib/src/visualizations/chart/Editor/GeneralSettings.tsx index 6b64db603f..4ca7415831 100644 --- a/viz-lib/src/visualizations/chart/Editor/GeneralSettings.tsx +++ b/viz-lib/src/visualizations/chart/Editor/GeneralSettings.tsx @@ -3,7 +3,7 @@ import React, { useMemo } from "react"; import { Section, Select, Checkbox, InputNumber, ContextHelp, Input } from "@/components/visualizations/editor"; import { UpdateOptionsStrategy } from "@/components/visualizations/editor/createTabbedEditor"; import { EditorPropTypes } from "@/visualizations/prop-types"; - +import { AllColorPalettes } from "@/visualizations/ColorPalette"; import ChartTypeSelect from "./ChartTypeSelect"; import ColumnMappingSelect from "./ColumnMappingSelect"; import { useDebouncedCallback } from "use-debounce/lib"; diff --git a/viz-lib/src/visualizations/chart/Editor/PieColorsSettings.tsx b/viz-lib/src/visualizations/chart/Editor/PieColorsSettings.tsx index 678a735c91..ff4fe2a0f8 100644 --- a/viz-lib/src/visualizations/chart/Editor/PieColorsSettings.tsx +++ b/viz-lib/src/visualizations/chart/Editor/PieColorsSettings.tsx @@ -3,8 +3,9 @@ import React, { useMemo, useCallback } from "react"; import Table from "antd/lib/table"; import ColorPicker from "@/components/ColorPicker"; import { EditorPropTypes } from "@/visualizations/prop-types"; -import ColorPalette from "@/visualizations/ColorPalette"; +import { AllColorPalettes } from "@/visualizations/ColorPalette"; import getChartData from "../getChartData"; +import { Section, Select } from "@/components/visualizations/editor"; function getUniqueValues(chartData: any) { const uniqueValuesNames = new Set(); @@ -20,9 +21,10 @@ export default function PieColorsSettings({ options, data, onOptionsChange }: an const colors = useMemo( () => ({ Automatic: null, - ...ColorPalette, + // @ts-expect-error ts-migrate(7053) FIXME: Element implicitly has an 'any' type because expre... Remove this comment to see the full error message + ...AllColorPalettes[options.color_scheme], }), - [] + [options.color_scheme] ); const series = useMemo( @@ -78,7 +80,24 @@ export default function PieColorsSettings({ options, data, onOptionsChange }: an }, ]; - return
; + return ( + + {/* @ts-expect-error ts-migrate(2745) FIXME: This JSX tag's 'children' prop expects type 'never... Remove this comment to see the full error message */} +
+ +
+
+ + ) } PieColorsSettings.propTypes = EditorPropTypes; diff --git a/viz-lib/src/visualizations/chart/getOptions.ts b/viz-lib/src/visualizations/chart/getOptions.ts index 5979f01332..6ede5bf828 100644 --- a/viz-lib/src/visualizations/chart/getOptions.ts +++ b/viz-lib/src/visualizations/chart/getOptions.ts @@ -17,6 +17,7 @@ const DEFAULT_OPTIONS = { sizemode: "diameter", coefficient: 1, piesort: true, + color_scheme: "Redash", // showDataLabels: false, // depends on chart type numberFormat: "0,0[.]00000", diff --git a/viz-lib/src/visualizations/chart/plotly/fixtures/prepareData/pie/custom-tooltip.json b/viz-lib/src/visualizations/chart/plotly/fixtures/prepareData/pie/custom-tooltip.json index b63d0b61b9..6c14eb0964 100644 --- a/viz-lib/src/visualizations/chart/plotly/fixtures/prepareData/pie/custom-tooltip.json +++ b/viz-lib/src/visualizations/chart/plotly/fixtures/prepareData/pie/custom-tooltip.json @@ -14,7 +14,8 @@ "columnMapping": { "x": "x", "y": "y" - } + }, + "color_scheme": "Redash" }, "data": [ { @@ -47,7 +48,8 @@ "textfont": { "color": ["#ffffff", "#ffffff", "#333333", "#ffffff"] }, "name": "a", "direction": "counterclockwise", - "domain": { "x": [0, 0.98], "y": [0, 0.9] } + "domain": { "x": [0, 0.98], "y": [0, 0.9] }, + "color_scheme": "Redash" } ] } diff --git a/viz-lib/src/visualizations/chart/plotly/fixtures/prepareData/pie/default.json b/viz-lib/src/visualizations/chart/plotly/fixtures/prepareData/pie/default.json index 17db9a53cb..b909ad4dff 100644 --- a/viz-lib/src/visualizations/chart/plotly/fixtures/prepareData/pie/default.json +++ b/viz-lib/src/visualizations/chart/plotly/fixtures/prepareData/pie/default.json @@ -14,7 +14,8 @@ "columnMapping": { "x": "x", "y": "y" - } + }, + "color_scheme": "Redash" }, "data": [ { @@ -47,7 +48,8 @@ "textfont": { "color": ["#ffffff", "#ffffff", "#333333", "#ffffff"] }, "name": "a", "direction": "counterclockwise", - "domain": { "x": [0, 0.98], "y": [0, 0.9] } + "domain": { "x": [0, 0.98], "y": [0, 0.9] }, + "color_scheme": "Redash" } ] } diff --git a/viz-lib/src/visualizations/chart/plotly/fixtures/prepareData/pie/without-labels.json b/viz-lib/src/visualizations/chart/plotly/fixtures/prepareData/pie/without-labels.json index 7bc2b208fa..49e4dbd40d 100644 --- a/viz-lib/src/visualizations/chart/plotly/fixtures/prepareData/pie/without-labels.json +++ b/viz-lib/src/visualizations/chart/plotly/fixtures/prepareData/pie/without-labels.json @@ -14,7 +14,8 @@ "columnMapping": { "x": "x", "y": "y" - } + }, + "color_scheme": "Redash" }, "data": [ { @@ -47,7 +48,8 @@ "textfont": { "color": ["#ffffff", "#ffffff", "#333333", "#ffffff"] }, "name": "a", "direction": "counterclockwise", - "domain": { "x": [0, 0.98], "y": [0, 0.9] } + "domain": { "x": [0, 0.98], "y": [0, 0.9] }, + "color_scheme": "Redash" } ] } diff --git a/viz-lib/src/visualizations/chart/plotly/fixtures/prepareData/pie/without-x.json b/viz-lib/src/visualizations/chart/plotly/fixtures/prepareData/pie/without-x.json index e0fb71e5ae..bad593591f 100644 --- a/viz-lib/src/visualizations/chart/plotly/fixtures/prepareData/pie/without-x.json +++ b/viz-lib/src/visualizations/chart/plotly/fixtures/prepareData/pie/without-x.json @@ -10,7 +10,8 @@ "direction": { "type": "counterclockwise" }, "xAxis": { "type": "-", "labels": { "enabled": true } }, "yAxis": [{ "type": "linear" }, { "type": "linear", "opposite": true }], - "series": { "stacking": null, "error_y": { "type": "data", "visible": true } } + "series": { "stacking": null, "error_y": { "type": "data", "visible": true } }, + "color_scheme": "Redash" }, "data": [ { @@ -43,7 +44,8 @@ "textfont": { "color": ["#ffffff"] }, "name": "a", "direction": "counterclockwise", - "domain": { "x": [0, 0.98], "y": [0, 0.9] } + "domain": { "x": [0, 0.98], "y": [0, 0.9] }, + "color_scheme": "Redash" } ] } diff --git a/viz-lib/src/visualizations/chart/plotly/prepareDefaultData.ts b/viz-lib/src/visualizations/chart/plotly/prepareDefaultData.ts index 191bce921f..8dfbb942a7 100644 --- a/viz-lib/src/visualizations/chart/plotly/prepareDefaultData.ts +++ b/viz-lib/src/visualizations/chart/plotly/prepareDefaultData.ts @@ -1,10 +1,18 @@ import { isNil, extend, each, includes, map, sortBy, toString } from "lodash"; import chooseTextColorForBackground from "@/lib/chooseTextColorForBackground"; -import { ColorPaletteArray } from "@/visualizations/ColorPalette"; +import { AllColorPaletteArrays, ColorPaletteTypes } from "@/visualizations/ColorPalette"; import { cleanNumber, normalizeValue, getSeriesAxis } from "./utils"; -function getSeriesColor(seriesOptions: any, seriesIndex: any) { - return seriesOptions.color || ColorPaletteArray[seriesIndex % ColorPaletteArray.length]; +function getSeriesColor(options: any, seriesOptions: any, seriesIndex: any, numSeries: any) { + // @ts-expect-error ts-migrate(7053) FIXME: Element implicitly has an 'any' type because expre... Remove this comment to see the full error message + let palette = AllColorPaletteArrays[options.color_scheme]; + // @ts-expect-error ts-migrate(7053) FIXME: Element implicitly has an 'any' type because expre... Remove this comment to see the full error message + if (ColorPaletteTypes[options.color_scheme] === 'continuous' && palette.length > numSeries) { + const step = (palette.length - 1) / (numSeries - 1 || 1); + const index = Math.round(step * seriesIndex); + return seriesOptions.color || palette[index % palette.length]; + } + return seriesOptions.color || palette[seriesIndex % palette.length]; } function getHoverInfoPattern(options: any) { @@ -71,11 +79,11 @@ function prepareBoxSeries(series: any, options: any, { seriesColor }: any) { return series; } -function prepareSeries(series: any, options: any, additionalOptions: any) { +function prepareSeries(series: any, options: any, numSeries: any, additionalOptions: any) { const { hoverInfoPattern, index } = additionalOptions; const seriesOptions = extend({ type: options.globalSeriesType, yAxis: 0 }, options.seriesOptions[series.name]); - const seriesColor = getSeriesColor(seriesOptions, index); + const seriesColor = getSeriesColor(options, seriesOptions, index, numSeries); const seriesYAxis = getSeriesAxis(series, options); // Sort by x - `Map` preserves order of items @@ -166,6 +174,7 @@ export default function prepareDefaultData(seriesList: any, options: any) { const additionalOptions = { hoverInfoPattern: getHoverInfoPattern(options), }; + const numSeries = seriesList.length - return map(seriesList, (series, index) => prepareSeries(series, options, { ...additionalOptions, index })); + return map(seriesList, (series, index) => prepareSeries(series, options, numSeries, { ...additionalOptions, index })); } diff --git a/viz-lib/src/visualizations/chart/plotly/preparePieData.ts b/viz-lib/src/visualizations/chart/plotly/preparePieData.ts index cce30c2df6..02424a5e65 100644 --- a/viz-lib/src/visualizations/chart/plotly/preparePieData.ts +++ b/viz-lib/src/visualizations/chart/plotly/preparePieData.ts @@ -1,7 +1,7 @@ import { isString, each, extend, includes, map, reduce } from "lodash"; import d3 from "d3"; import chooseTextColorForBackground from "@/lib/chooseTextColorForBackground"; -import { ColorPaletteArray } from "@/visualizations/ColorPalette"; +import { AllColorPaletteArrays, ColorPaletteTypes } from "@/visualizations/ColorPalette"; import { cleanNumber, normalizeValue } from "./utils"; @@ -35,7 +35,6 @@ function prepareSeries(series: any, options: any, additionalOptions: any) { hoverInfoPattern, getValueColor, } = additionalOptions; - const seriesOptions = extend({ type: options.globalSeriesType, yAxis: 0 }, options.seriesOptions[series.name]); const xPosition = (index % cellsInRow) * cellWidth; @@ -102,17 +101,35 @@ function prepareSeries(series: any, options: any, additionalOptions: any) { }, sourceData, sort: options.piesort, + color_scheme: options.color_scheme, }; } export default function preparePieData(seriesList: any, options: any) { - // we will use this to assign colors for values that have no explicitly set color - // @ts-expect-error ts-migrate(2339) FIXME: Property 'scale' does not exist on type 'typeof im... Remove this comment to see the full error message - const getDefaultColor = d3.scale - .ordinal() - .domain([]) - .range(ColorPaletteArray); + // @ts-expect-error ts-migrate(7053) FIXME: Element implicitly has an 'any' type because expre... Remove this comment to see the full error message + const palette = AllColorPaletteArrays[options.color_scheme]; const valuesColors = {}; + let getDefaultColor : Function; + + // @ts-expect-error ts-migrate(7053) FIXME: Element implicitly has an 'any' type because expre... Remove this comment to see the full error message + if (typeof(seriesList[0]) !== 'undefined' && ColorPaletteTypes[options.color_scheme] === 'continuous') { + const uniqueXValues =[... new Set(seriesList[0].data.map((d: any) => d.x))]; + const step = (palette.length - 1) / (uniqueXValues.length - 1 || 1); + const colorIndices = d3.range(uniqueXValues.length).map(function(i) { + return Math.round(step * i); + }); + // @ts-expect-error ts-migrate(2339) FIXME: Property 'scale' does not exist on type 'typeof im... Remove this comment to see the full error message + getDefaultColor = d3.scale.ordinal() + .domain(uniqueXValues) // Set domain as the unique x-values + .range(colorIndices.map(index => palette[index])); + } else { + // @ts-expect-error ts-migrate(2339) FIXME: Property 'scale' does not exist on type 'typeof im... Remove this comment to see the full error message + getDefaultColor = d3.scale + .ordinal() + .domain([]) + .range(palette); + }; + each(options.valuesOptions, (item, key) => { if (isString(item.color) && item.color !== "") { // @ts-expect-error ts-migrate(7053) FIXME: Element implicitly has an 'any' type because expre... Remove this comment to see the full error message