diff --git a/superset-frontend/packages/superset-ui-chart-controls/src/operators/histogramOperator.ts b/superset-frontend/packages/superset-ui-chart-controls/src/operators/histogramOperator.ts new file mode 100644 index 000000000..38b8ecf10 --- /dev/null +++ b/superset-frontend/packages/superset-ui-chart-controls/src/operators/histogramOperator.ts @@ -0,0 +1,40 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF 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 limitationsxw + * under the License. + */ +import { PostProcessingHistogram, getColumnLabel } from '@superset-ui/core'; +import { PostProcessingFactory } from './types'; + +/* eslint-disable @typescript-eslint/no-unused-vars */ +export const histogramOperator: PostProcessingFactory< + PostProcessingHistogram +> = (formData, queryObject) => { + const { bins, column, cumulative, groupby, normalize } = formData; + const parsedBins = Number.isNaN(Number(bins)) ? 5 : Number(bins); + const parsedColumn = getColumnLabel(column); + const parsedGroupBy = groupby!.map(getColumnLabel); + return { + operation: 'histogram', + options: { + column: parsedColumn, + groupby: parsedGroupBy, + bins: parsedBins, + cumulative, + normalize, + }, + }; +}; diff --git a/superset-frontend/packages/superset-ui-chart-controls/src/operators/index.ts b/superset-frontend/packages/superset-ui-chart-controls/src/operators/index.ts index c7151dafd..cac7088a7 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/src/operators/index.ts +++ b/superset-frontend/packages/superset-ui-chart-controls/src/operators/index.ts @@ -21,6 +21,7 @@ export { rollingWindowOperator } from './rollingWindowOperator'; export { timeCompareOperator } from './timeCompareOperator'; export { timeComparePivotOperator } from './timeComparePivotOperator'; export { sortOperator } from './sortOperator'; +export { histogramOperator } from './histogramOperator'; export { pivotOperator } from './pivotOperator'; export { resampleOperator } from './resampleOperator'; export { renameOperator } from './renameOperator'; diff --git a/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/index.ts b/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/index.ts index ec769a871..8ff85439b 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/index.ts +++ b/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/index.ts @@ -22,3 +22,4 @@ export { default as sharedControlComponents } from './components'; export * from './components'; export * from './customControls'; export * from './mixins'; +export * from './dndControls'; diff --git a/superset-frontend/packages/superset-ui-chart-controls/src/utils/columnChoices.ts b/superset-frontend/packages/superset-ui-chart-controls/src/utils/columnChoices.ts index f05615175..fe2c0fa92 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/src/utils/columnChoices.ts +++ b/superset-frontend/packages/superset-ui-chart-controls/src/utils/columnChoices.ts @@ -16,26 +16,48 @@ * specific language governing permissions and limitations * under the License. */ -import { QueryResponse } from '@superset-ui/core'; -import { Dataset, isDataset, isQueryResponse } from '../types'; +import { GenericDataType, QueryColumn, QueryResponse } from '@superset-ui/core'; +import { ColumnMeta, Dataset, isDataset, isQueryResponse } from '../types'; + +export function columnsByType( + datasource?: Dataset | QueryResponse | null, + type?: GenericDataType, +): (ColumnMeta | QueryColumn)[] { + if (isDataset(datasource) || isQueryResponse(datasource)) { + const columns = datasource.columns as (ColumnMeta | QueryColumn)[]; + const filteredColumns = columns.filter( + col => type === undefined || col.type_generic === type, + ); + return filteredColumns.sort( + (col1: ColumnMeta | QueryColumn, col2: ColumnMeta | QueryColumn) => { + const opt1Name = + 'verbose_name' in col1 + ? col1.verbose_name || col1.column_name + : col1.column_name; + const opt2Name = + 'verbose_name' in col2 + ? col2.verbose_name || col2.column_name + : col2.column_name; + return opt1Name.toLowerCase() > opt2Name.toLowerCase() ? 1 : -1; + }, + ); + } + return []; +} /** * Convert Datasource columns to column choices */ export default function columnChoices( datasource?: Dataset | QueryResponse | null, + type?: GenericDataType, ): [string, string][] { - if (isDataset(datasource) || isQueryResponse(datasource)) { - return datasource.columns - .map((col): [string, string] => [ - col.column_name, - 'verbose_name' in col - ? col.verbose_name || col.column_name - : col.column_name, - ]) - .sort((opt1, opt2) => - opt1[1].toLowerCase() > opt2[1].toLowerCase() ? 1 : -1, - ); - } - return []; + return columnsByType(datasource, type).map( + (col: ColumnMeta | QueryColumn): [string, string] => [ + col.column_name, + 'verbose_name' in col + ? col.verbose_name || col.column_name + : col.column_name, + ], + ); } diff --git a/superset-frontend/packages/superset-ui-chart-controls/src/utils/index.ts b/superset-frontend/packages/superset-ui-chart-controls/src/utils/index.ts index fb829ea05..77e883caf 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/src/utils/index.ts +++ b/superset-frontend/packages/superset-ui-chart-controls/src/utils/index.ts @@ -22,7 +22,7 @@ export * from './D3Formatting'; export * from './expandControlConfig'; export * from './getColorFormatters'; export { default as mainMetric } from './mainMetric'; -export { default as columnChoices } from './columnChoices'; +export { default as columnChoices, columnsByType } from './columnChoices'; export * from './defineSavedMetrics'; export * from './getStandardizedControls'; export * from './getTemporalColumns'; diff --git a/superset-frontend/packages/superset-ui-chart-controls/src/utils/selectOptions.ts b/superset-frontend/packages/superset-ui-chart-controls/src/utils/selectOptions.ts index 666ab35f4..9872439f1 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/src/utils/selectOptions.ts +++ b/superset-frontend/packages/superset-ui-chart-controls/src/utils/selectOptions.ts @@ -35,9 +35,13 @@ export function formatSelectOptions( * >> formatSelectOptionsForRange(1, 5) * >> [[1,'1'], [2,'2'], [3,'3'], [4,'4'], [5,'5']] */ -export function formatSelectOptionsForRange(start: number, end: number) { +export function formatSelectOptionsForRange( + start: number, + end: number, + step = 1, +): Formatted[] { const options: Formatted[] = []; - for (let i = start; i <= end; i += 1) { + for (let i = start; i <= end; i += step) { options.push([i, i.toString()]); } return options; diff --git a/superset-frontend/packages/superset-ui-chart-controls/test/operators/histogramOperator.test.ts b/superset-frontend/packages/superset-ui-chart-controls/test/operators/histogramOperator.test.ts new file mode 100644 index 000000000..46be335fd --- /dev/null +++ b/superset-frontend/packages/superset-ui-chart-controls/test/operators/histogramOperator.test.ts @@ -0,0 +1,47 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF 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 { histogramOperator } from '@superset-ui/chart-controls'; +import { SqlaFormData } from '@superset-ui/core'; +import { omit } from 'lodash'; + +const formData: SqlaFormData = { + bins: 5, + column: 'quantity', + cumulative: true, + normalize: true, + groupby: ['country', 'region'], + viz_type: 'histogram', + datasource: 'foo', +}; + +test('matches formData', () => { + expect(histogramOperator(formData, {})).toEqual({ + operation: 'histogram', + options: omit(formData, ['viz_type', 'datasource']), + }); +}); + +test('defaults to 5 bins', () => { + expect( + histogramOperator(omit(formData, ['bins']) as SqlaFormData, {}), + ).toEqual({ + operation: 'histogram', + options: omit(formData, ['viz_type', 'datasource']), + }); +}); diff --git a/superset-frontend/packages/superset-ui-chart-controls/test/utils/columnChoices.test.tsx b/superset-frontend/packages/superset-ui-chart-controls/test/utils/columnChoices.test.tsx index 2fac0e60e..f27365e95 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/test/utils/columnChoices.test.tsx +++ b/superset-frontend/packages/superset-ui-chart-controls/test/utils/columnChoices.test.tsx @@ -74,6 +74,44 @@ describe('columnChoices()', () => { ['Column 2', 'Column 2'], ['Column 3', 'Column 3'], ]); - expect.anything(); + }); + + it('should return choices of a specific type', () => { + expect(columnChoices(testQueryResponse, GenericDataType.Temporal)).toEqual([ + ['Column 2', 'Column 2'], + ]); + }); + it('should use name when verbose_name key exists but is not defined', () => { + expect( + columnChoices({ + id: 1, + metrics: [], + type: DatasourceType.Table, + main_dttm_col: 'test', + time_grain_sqla: [], + columns: [ + { + column_name: 'foo', + verbose_name: null, + type: 'VARCHAR', + type_generic: GenericDataType.String, + }, + { + column_name: 'bar', + verbose_name: null, + type: 'VARCHAR', + type_generic: GenericDataType.String, + }, + ], + verbose_map: {}, + column_formats: { fiz: 'NUMERIC', about: 'STRING', foo: 'DATE' }, + currency_formats: {}, + datasource_name: 'my_datasource', + description: 'this is my datasource', + }), + ).toEqual([ + ['bar', 'bar'], + ['foo', 'foo'], + ]); }); }); diff --git a/superset-frontend/packages/superset-ui-core/src/query/types/PostProcessing.ts b/superset-frontend/packages/superset-ui-core/src/query/types/PostProcessing.ts index 3b4094133..a70d0111f 100644 --- a/superset-frontend/packages/superset-ui-core/src/query/types/PostProcessing.ts +++ b/superset-frontend/packages/superset-ui-core/src/query/types/PostProcessing.ts @@ -233,6 +233,20 @@ interface _PostProcessingRank { } export type PostProcessingRank = _PostProcessingRank | DefaultPostProcessing; +interface _PostProcessingHistogram { + operation: 'histogram'; + options?: { + column: string; + groupby: string[]; + bins: number; + cumulative?: boolean; + normalize?: boolean; + }; +} +export type PostProcessingHistogram = + | _PostProcessingHistogram + | DefaultPostProcessing; + /** * Parameters for chart data postprocessing. * See superset/utils/pandas_processing.py. @@ -251,6 +265,7 @@ export type PostProcessingRule = | PostProcessingResample | PostProcessingRename | PostProcessingFlatten + | PostProcessingHistogram | PostProcessingRank; export function isPostProcessingAggregation( @@ -318,3 +333,27 @@ export function isPostProcessingResample( ): rule is PostProcessingResample { return rule?.operation === 'resample'; } + +export function isPostProcessingRename( + rule?: PostProcessingRule, +): rule is PostProcessingRename { + return rule?.operation === 'rename'; +} + +export function isPostProcessingFlatten( + rule?: PostProcessingRule, +): rule is PostProcessingFlatten { + return rule?.operation === 'flatten'; +} + +export function isPostProcessingRank( + rule?: PostProcessingRule, +): rule is PostProcessingRank { + return rule?.operation === 'rank'; +} + +export function isPostProcessingHistogram( + rule?: PostProcessingRule, +): rule is PostProcessingHistogram { + return rule?.operation === 'histogram'; +} diff --git a/superset-frontend/packages/superset-ui-core/test/query/types/PostProcessing.test.ts b/superset-frontend/packages/superset-ui-core/test/query/types/PostProcessing.test.ts index 047699fa5..05c385fb4 100644 --- a/superset-frontend/packages/superset-ui-core/test/query/types/PostProcessing.test.ts +++ b/superset-frontend/packages/superset-ui-core/test/query/types/PostProcessing.test.ts @@ -41,6 +41,14 @@ import { PostProcessingResample, PostProcessingRolling, PostProcessingSort, + PostProcessingHistogram, + isPostProcessingHistogram, + PostProcessingRename, + PostProcessingFlatten, + PostProcessingRank, + isPostProcessingRename, + isPostProcessingFlatten, + isPostProcessingRank, } from '@superset-ui/core'; import { ComparisonType, RollingType, TimeGranularity } from '../../../src'; @@ -151,6 +159,38 @@ const SORT_RULE: PostProcessingSort = { }, }; +const HISTOGRAM_RULE: PostProcessingHistogram = { + operation: 'histogram', + options: { + column: 'foo', + groupby: ['bar'], + bins: 5, + normalize: true, + cumulative: true, + }, +}; + +const RENAME_RULE: PostProcessingRename = { + operation: 'rename', + options: { + columns: { + foo: 'bar', + }, + }, +}; + +const FLATTEN_RULE: PostProcessingFlatten = { + operation: 'flatten', +}; + +const RANK_RULE: PostProcessingRank = { + operation: 'rank', + options: { + metric: 'foo', + group_by: 'bar', + }, +}; + test('PostProcessingAggregation type guard', () => { expect(isPostProcessingAggregation(AGGREGATE_RULE)).toEqual(true); expect(isPostProcessingAggregation(BOXPLOT_RULE)).toEqual(false); @@ -216,3 +256,27 @@ test('PostProcessingSort type guard', () => { expect(isPostProcessingSort(AGGREGATE_RULE)).toEqual(false); expect(isPostProcessingSort(undefined)).toEqual(false); }); + +test('PostProcessingHistogram type guard', () => { + expect(isPostProcessingHistogram(HISTOGRAM_RULE)).toEqual(true); + expect(isPostProcessingHistogram(AGGREGATE_RULE)).toEqual(false); + expect(isPostProcessingHistogram(undefined)).toEqual(false); +}); + +test('PostProcessingRename type guard', () => { + expect(isPostProcessingRename(RENAME_RULE)).toEqual(true); + expect(isPostProcessingRename(AGGREGATE_RULE)).toEqual(false); + expect(isPostProcessingRename(undefined)).toEqual(false); +}); + +test('PostProcessingFlatten type guard', () => { + expect(isPostProcessingFlatten(FLATTEN_RULE)).toEqual(true); + expect(isPostProcessingFlatten(AGGREGATE_RULE)).toEqual(false); + expect(isPostProcessingFlatten(undefined)).toEqual(false); +}); + +test('PostProcessingRank type guard', () => { + expect(isPostProcessingRank(RANK_RULE)).toEqual(true); + expect(isPostProcessingRank(AGGREGATE_RULE)).toEqual(false); + expect(isPostProcessingRank(undefined)).toEqual(false); +}); diff --git a/superset-frontend/plugins/legacy-plugin-chart-histogram/src/index.js b/superset-frontend/plugins/legacy-plugin-chart-histogram/src/index.js index e14ab372c..5704f5833 100644 --- a/superset-frontend/plugins/legacy-plugin-chart-histogram/src/index.js +++ b/superset-frontend/plugins/legacy-plugin-chart-histogram/src/index.js @@ -34,7 +34,7 @@ const metadata = new ChartMetadata({ { url: example2 }, { url: example3 }, ], - name: t('Histogram'), + name: t('Histogram (legacy)'), tags: [t('Comparison'), t('Legacy'), t('Pattern'), t('Range')], thumbnail, useLegacyApi: true, diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Histogram/Histogram.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Histogram/Histogram.tsx new file mode 100644 index 000000000..08641b27c --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Histogram/Histogram.tsx @@ -0,0 +1,61 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import React from 'react'; +import { HistogramTransformedProps } from './types'; +import Echart from '../components/Echart'; +import { EventHandlers } from '../types'; + +export default function Histogram(props: HistogramTransformedProps) { + const { + height, + width, + echartOptions, + onFocusedSeries, + onLegendStateChanged, + refs, + } = props; + + const eventHandlers: EventHandlers = { + legendselectchanged: payload => { + onLegendStateChanged?.(payload.selected); + }, + legendselectall: payload => { + onLegendStateChanged?.(payload.selected); + }, + legendinverseselect: payload => { + onLegendStateChanged?.(payload.selected); + }, + mouseout: () => { + onFocusedSeries(undefined); + }, + mouseover: params => { + onFocusedSeries(params.seriesIndex); + }, + }; + + return ( + + ); +} diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Histogram/buildQuery.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Histogram/buildQuery.ts new file mode 100644 index 000000000..351660ba6 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Histogram/buildQuery.ts @@ -0,0 +1,32 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF 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 { buildQueryContext } from '@superset-ui/core'; +import { histogramOperator } from '@superset-ui/chart-controls'; +import { HistogramFormData } from './types'; + +export default function buildQuery(formData: HistogramFormData) { + const { column, groupby = [] } = formData; + return buildQueryContext(formData, baseQueryObject => [ + { + ...baseQueryObject, + columns: [...groupby, column], + post_processing: [histogramOperator(formData, baseQueryObject)], + }, + ]); +} diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Histogram/controlPanel.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Histogram/controlPanel.tsx new file mode 100644 index 000000000..59a7de282 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Histogram/controlPanel.tsx @@ -0,0 +1,141 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF 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 { + GenericDataType, + t, + validateInteger, + validateNonEmpty, +} from '@superset-ui/core'; +import { + ControlPanelConfig, + formatSelectOptionsForRange, + dndGroupByControl, + columnsByType, +} from '@superset-ui/chart-controls'; +import { showLegendControl, showValueControl } from '../controls'; + +const config: ControlPanelConfig = { + controlPanelSections: [ + { + label: t('Query'), + expanded: true, + controlSetRows: [ + [ + { + name: 'column', + config: { + ...dndGroupByControl, + label: t('Column'), + multi: false, + description: t('Numeric column used to calculate the histogram.'), + validators: [validateNonEmpty], + freeForm: false, + disabledTabs: new Set(['saved', 'sqlExpression']), + mapStateToProps: ({ datasource }) => ({ + options: columnsByType(datasource, GenericDataType.Numeric), + }), + }, + }, + ], + ['groupby'], + ['adhoc_filters'], + ['row_limit'], + [ + { + name: 'bins', + config: { + type: 'SelectControl', + freeForm: true, + label: t('Bins'), + default: 5, + choices: formatSelectOptionsForRange(5, 20, 5), + description: t('The number of bins for the histogram'), + validators: [validateInteger], + }, + }, + ], + [ + { + name: 'normalize', + config: { + type: 'CheckboxControl', + label: t('Normalize'), + description: t(` + The normalize option transforms the histogram values into proportions or + probabilities by dividing each bin's count by the total count of data points. + This normalization process ensures that the resulting values sum up to 1, + enabling a relative comparison of the data's distribution and providing a + clearer understanding of the proportion of data points within each bin.`), + default: false, + }, + }, + ], + [ + { + name: 'cumulative', + config: { + type: 'CheckboxControl', + label: t('Cumulative'), + description: t(` + The cumulative option allows you to see how your data accumulates over different + values. When enabled, the histogram bars represent the running total of frequencies + up to each bin. This helps you understand how likely it is to encounter values + below a certain point. Keep in mind that enabling cumulative doesn't change your + original data, it just changes the way the histogram is displayed.`), + default: false, + }, + }, + ], + ], + }, + { + label: t('Chart Options'), + expanded: true, + controlSetRows: [ + ['color_scheme'], + [showValueControl], + [showLegendControl], + [ + { + name: 'x_axis_title', + config: { + type: 'TextControl', + label: t('X Axis Title'), + renderTrigger: true, + default: '', + }, + }, + ], + [ + { + name: 'y_axis_title', + config: { + type: 'TextControl', + label: t('Y Axis Title'), + renderTrigger: true, + default: '', + }, + }, + ], + ], + }, + ], +}; + +export default config; diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Histogram/images/example1.png b/superset-frontend/plugins/plugin-chart-echarts/src/Histogram/images/example1.png new file mode 100644 index 000000000..45955dd05 Binary files /dev/null and b/superset-frontend/plugins/plugin-chart-echarts/src/Histogram/images/example1.png differ diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Histogram/images/example2.png b/superset-frontend/plugins/plugin-chart-echarts/src/Histogram/images/example2.png new file mode 100644 index 000000000..ab17b5b0e Binary files /dev/null and b/superset-frontend/plugins/plugin-chart-echarts/src/Histogram/images/example2.png differ diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Histogram/images/thumbnail.png b/superset-frontend/plugins/plugin-chart-echarts/src/Histogram/images/thumbnail.png new file mode 100644 index 000000000..c4e5b22de Binary files /dev/null and b/superset-frontend/plugins/plugin-chart-echarts/src/Histogram/images/thumbnail.png differ diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Histogram/index.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Histogram/index.ts new file mode 100644 index 000000000..6e732d35e --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Histogram/index.ts @@ -0,0 +1,66 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regardin + * g copyright ownership. The ASF 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 { Behavior, ChartMetadata, ChartPlugin, t } from '@superset-ui/core'; +import buildQuery from './buildQuery'; +import controlPanel from './controlPanel'; +import transformProps from './transformProps'; +import thumbnail from './images/thumbnail.png'; +import example1 from './images/example1.png'; +import example2 from './images/example2.png'; +import { HistogramChartProps, HistogramFormData } from './types'; + +export default class EchartsHistogramChartPlugin extends ChartPlugin< + HistogramFormData, + HistogramChartProps +> { + /** + * The constructor is used to pass relevant metadata and callbacks that get + * registered in respective registries that are used throughout the library + * and application. A more thorough description of each property is given in + * the respective imported file. + * + * It is worth noting that `buildQuery` and is optional, and only needed for + * advanced visualizations that require either post processing operations + * (pivoting, rolling aggregations, sorting etc) or submitting multiple queries. + */ + constructor() { + super({ + buildQuery, + controlPanel, + loadChart: () => import('./Histogram'), + metadata: new ChartMetadata({ + behaviors: [Behavior.InteractiveChart], + credits: ['https://echarts.apache.org'], + category: t('Distribution'), + description: t( + `The histogram chart displays the distribution of a dataset by + representing the frequency or count of values within different ranges or bins. + It helps visualize patterns, clusters, and outliers in the data and provides + insights into its shape, central tendency, and spread.`, + ), + exampleGallery: [{ url: example1 }, { url: example2 }], + name: t('Histogram'), + tags: [t('Comparison'), t('ECharts'), t('Pattern'), t('Range')], + thumbnail, + }), + transformProps, + }); + } +} diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Histogram/transformProps.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Histogram/transformProps.ts new file mode 100644 index 000000000..474cd95ba --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Histogram/transformProps.ts @@ -0,0 +1,188 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF 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 { BarSeriesOption, EChartsOption } from 'echarts'; +import { CallbackDataParams } from 'echarts/types/src/util/types'; +import { isEmpty } from 'lodash'; +import { + CategoricalColorNamespace, + NumberFormats, + getColumnLabel, + getNumberFormatter, + tooltipHtml, +} from '@superset-ui/core'; +import { HistogramChartProps, HistogramTransformedProps } from './types'; +import { LegendOrientation, LegendType, Refs } from '../types'; +import { defaultGrid, defaultYAxis } from '../defaults'; +import { getLegendProps } from '../utils/series'; +import { getDefaultTooltip } from '../utils/tooltip'; +import { getPercentFormatter } from '../utils/formatters'; + +export default function transformProps( + chartProps: HistogramChartProps, +): HistogramTransformedProps { + const refs: Refs = {}; + let focusedSeries: number | undefined; + const { + formData, + height, + hooks, + legendState = {}, + queriesData, + theme, + width, + } = chartProps; + const { onLegendStateChanged } = hooks; + const { + colorScheme, + column, + groupby = [], + normalize, + showLegend, + showValue, + sliceId, + xAxisTitle, + yAxisTitle, + } = formData; + const { data } = queriesData[0]; + const colorFn = CategoricalColorNamespace.getScale(colorScheme); + const formatter = getNumberFormatter( + normalize ? NumberFormats.FLOAT_2_POINT : NumberFormats.INTEGER, + ); + const percentFormatter = getPercentFormatter(NumberFormats.PERCENT_2_POINT); + const groupbySet = new Set(groupby); + const xAxisData: string[] = Object.keys(data[0]).filter( + key => !groupbySet.has(key), + ); + const barSeries: BarSeriesOption[] = data.map(datum => { + const seriesName = + groupby.length > 0 + ? groupby.map(key => datum[getColumnLabel(key)]).join(', ') + : getColumnLabel(column); + const seriesData = Object.keys(datum) + .filter(key => groupbySet.has(key) === false) + .map(key => datum[key] as number); + return { + name: seriesName, + type: 'bar', + data: seriesData, + itemStyle: { + color: colorFn(seriesName, sliceId), + }, + label: { + show: showValue, + position: 'top', + formatter: params => { + const { value } = params; + return formatter.format(value as number); + }, + }, + }; + }); + + const legendOptions = barSeries.map(series => series.name as string); + if (isEmpty(legendState)) { + legendOptions.forEach(legend => { + legendState[legend] = true; + }); + } + + const tooltipFormatter = (params: CallbackDataParams[]) => { + const title = params[0].name; + const rows = params.map(param => { + const { marker, seriesName, value } = param; + return [`${marker}${seriesName}`, formatter.format(value as number)]; + }); + if (groupby.length > 0) { + const total = params.reduce( + (acc, param) => acc + (param.value as number), + 0, + ); + if (!normalize) { + rows.forEach((row, i) => + row.push( + percentFormatter.format((params[i].value as number) / (total || 1)), + ), + ); + } + const totalRow = ['Total', formatter.format(total)]; + if (!normalize) { + totalRow.push(percentFormatter.format(1)); + } + rows.push(totalRow); + } + return tooltipHtml(rows, title, focusedSeries); + }; + + const onFocusedSeries = (index?: number | undefined) => { + focusedSeries = index; + }; + + const echartOptions: EChartsOption = { + grid: { + ...defaultGrid, + bottom: 30, + left: 30, + right: 30, + }, + xAxis: { + data: xAxisData, + name: xAxisTitle, + nameGap: 35, + type: 'category', + nameLocation: 'middle', + }, + yAxis: { + ...defaultYAxis, + name: yAxisTitle, + nameGap: normalize ? 55 : 40, + type: 'value', + nameLocation: 'middle', + axisLabel: { + formatter: (value: number) => formatter.format(value), + }, + }, + series: barSeries, + legend: { + ...getLegendProps( + LegendType.Scroll, + LegendOrientation.Top, + showLegend, + theme, + false, + legendState, + ), + data: legendOptions, + }, + tooltip: { + ...getDefaultTooltip(refs), + trigger: 'axis', + formatter: tooltipFormatter, + }, + }; + + return { + refs, + formData, + width, + height, + echartOptions, + onFocusedSeries, + onLegendStateChanged, + }; +} diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Histogram/types.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Histogram/types.ts new file mode 100644 index 000000000..ca6c16d79 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Histogram/types.ts @@ -0,0 +1,42 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF 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 { QueryFormColumn, QueryFormData } from '@superset-ui/core'; +import { BaseChartProps, BaseTransformedProps } from '../types'; + +export type HistogramFormData = QueryFormData & { + bins: number; + column: QueryFormColumn; + colorScheme?: string; + cumulative: boolean; + normalize: boolean; + sliceId: number; + showLegend: boolean; + showValue: boolean; + xAxisTitle: string; + yAxisTitle: string; +}; + +export interface HistogramChartProps extends BaseChartProps { + formData: HistogramFormData; +} + +export type HistogramTransformedProps = + BaseTransformedProps & { + onFocusedSeries: (index: number | undefined) => void; + }; diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/controls.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/controls.tsx index c91d27acc..3e38b480c 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/controls.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/controls.tsx @@ -34,7 +34,7 @@ import { defaultXAxis } from './defaults'; const { legendMargin, legendOrientation, legendType, showLegend } = DEFAULT_LEGEND_FORM_DATA; -const showLegendControl: ControlSetItem = { +export const showLegendControl: ControlSetItem = { name: 'show_legend', config: { type: 'CheckboxControl', diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/index.ts b/superset-frontend/plugins/plugin-chart-echarts/src/index.ts index f3bee6209..36290ec45 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/index.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/index.ts @@ -28,6 +28,7 @@ export { default as EchartsMixedTimeseriesChartPlugin } from './MixedTimeseries' export { default as EchartsPieChartPlugin } from './Pie'; export { default as EchartsGraphChartPlugin } from './Graph'; export { default as EchartsGaugeChartPlugin } from './Gauge'; +export { default as EchartsHistogramChartPlugin } from './Histogram'; export { default as EchartsRadarChartPlugin } from './Radar'; export { default as EchartsFunnelChartPlugin } from './Funnel'; export { default as EchartsTreeChartPlugin } from './Tree'; @@ -56,6 +57,7 @@ export { default as HeatmapTransformProps } from './Heatmap/transformProps'; export { default as SunburstTransformProps } from './Sunburst/transformProps'; export { default as BubbleTransformProps } from './Bubble/transformProps'; export { default as WaterfallTransformProps } from './Waterfall/transformProps'; +export { default as HistogramTransformProps } from './Histogram/transformProps'; export { DEFAULT_FORM_DATA as TimeseriesDefaultFormData } from './Timeseries/constants'; diff --git a/superset-frontend/src/explore/components/ExploreViewContainer/index.jsx b/superset-frontend/src/explore/components/ExploreViewContainer/index.jsx index fe5d27724..ff4209396 100644 --- a/superset-frontend/src/explore/components/ExploreViewContainer/index.jsx +++ b/superset-frontend/src/explore/components/ExploreViewContainer/index.jsx @@ -31,7 +31,7 @@ import { useComponentDidMount, usePrevious, } from '@superset-ui/core'; -import { debounce, omit, pick } from 'lodash'; +import { debounce, isEqual, isObjectLike, omit, pick } from 'lodash'; import { Resizable } from 're-resizable'; import { usePluginContext } from 'src/components/DynamicPlugins'; import { Global } from '@emotion/react'; @@ -460,15 +460,21 @@ function ExploreViewContainer(props) { const chartIsStale = useMemo(() => { if (lastQueriedControls) { - const changedControlKeys = Object.keys(props.controls).filter( - key => - typeof lastQueriedControls[key] !== 'undefined' && - !areObjectsEqual( - props.controls[key].value, - lastQueriedControls[key].value, - { ignoreFields: ['datasourceWarning'] }, - ), - ); + const { controls } = props; + const changedControlKeys = Object.keys(controls).filter(key => { + const lastControl = lastQueriedControls[key]; + if (typeof lastControl === 'undefined') { + return false; + } + const { value: value1 } = controls[key]; + const { value: value2 } = lastControl; + if (isObjectLike(value1) && isObjectLike(value2)) { + return !areObjectsEqual(value1, value2, { + ignoreFields: ['datasourceWarning'], + }); + } + return !isEqual(value1, value2); + }); return changedControlKeys.some( key => diff --git a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/ColumnSelectPopover.tsx b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/ColumnSelectPopover.tsx index 02d5c47b0..4644f97af 100644 --- a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/ColumnSelectPopover.tsx +++ b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/ColumnSelectPopover.tsx @@ -74,6 +74,7 @@ interface ColumnSelectPopoverProps { label: string; isTemporal?: boolean; setDatasetModal?: Dispatch>; + disabledTabs?: Set; } const getInitialColumnValues = ( @@ -102,6 +103,7 @@ const ColumnSelectPopover = ({ onClose, setDatasetModal, setLabel, + disabledTabs = new Set<'saved' | 'simple' | 'sqlExpression'>(), }: ColumnSelectPopoverProps) => { const datasourceType = useSelector( state => state.explore.datasource.type, @@ -299,7 +301,11 @@ const ColumnSelectPopover = ({ width: ${width}px; `} > - + {calculatedColumns.length > 0 ? ( )} - + {isTemporal && simpleColumns.length === 0 ? ( - + void; children: React.ReactNode; isTemporal?: boolean; + disabledTabs?: Set; } const defaultPopoverLabel = t('My column'); @@ -48,6 +49,7 @@ const ColumnSelectPopoverTrigger = ({ isControlledComponent, children, isTemporal, + disabledTabs, ...props }: ColumnSelectPopoverTriggerProps) => { // @ts-ignore @@ -108,6 +110,7 @@ const ColumnSelectPopoverTrigger = ({ setLabel={setPopoverLabel} getCurrentTab={getCurrentTab} isTemporal={isTemporal} + disabledTabs={disabledTabs} /> ), diff --git a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndColumnSelect.tsx b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndColumnSelect.tsx index ef6d39486..7ee006c83 100644 --- a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndColumnSelect.tsx +++ b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndColumnSelect.tsx @@ -37,6 +37,7 @@ import { DndControlProps } from './types'; export type DndColumnSelectProps = DndControlProps & { options: ColumnMeta[]; isTemporal?: boolean; + disabledTabs?: Set; }; function DndColumnSelect(props: DndColumnSelectProps) { @@ -50,6 +51,7 @@ function DndColumnSelect(props: DndColumnSelectProps) { name, label, isTemporal, + disabledTabs, } = props; const [newColumnPopoverVisible, setNewColumnPopoverVisible] = useState(false); @@ -121,6 +123,7 @@ function DndColumnSelect(props: DndColumnSelectProps) { }} editedColumn={column} isTemporal={isTemporal} + disabledTabs={disabledTabs} > DataFrame: + """ + Generate a histogram DataFrame from a given DataFrame. + + Parameters: + df (DataFrame): The input DataFrame. + column (str): The column of the DataFrame to calculate the histogram on. + groupby (list[str]): The columns to group by. If empty, no grouping is performed. + bins (int): The number of bins to use for the histogram. Default is 5. + cumulative (bool): Whether to calculate a cumulative histogram. Default is False. + normalize (bool): Whether to normalize the histogram. Default is False. + + Returns: + DataFrame: A DataFrame where each row corresponds to a group (or the entire DataFrame if no grouping is performed), + and each column corresponds to a histogram bin. The values are the counts in each bin. + """ + + if groupby is None: + groupby = [] + + # check if the column is numeric + if not np.issubdtype(df[column].dtype, np.number): + raise ValueError(f"The column '{column}' must be numeric.") + + # calculate the histogram bin edges + bin_edges = np.histogram_bin_edges(df[column], bins=bins) + + # convert the bin edges to strings + bin_edges_str = [ + f"{int(bin_edges[i])} - {int(bin_edges[i+1])}" + for i in range(len(bin_edges) - 1) + ] + + def hist_values(series: Series) -> np.ndarray: + result = np.histogram(series, bins=bin_edges)[0] + return result if not cumulative else np.cumsum(result) + + if len(groupby) == 0: + # without grouping + hist_dict = dict(zip(bin_edges_str, hist_values(df[column]))) + histogram_df = DataFrame(hist_dict, index=[0]) + else: + # with grouping + histogram_df = ( + df.groupby(groupby)[column] + .apply(lambda x: Series(hist_values(x))) + .unstack(fill_value=0) + ) + histogram_df.columns = bin_edges_str + + if normalize: + histogram_df = histogram_df / histogram_df.values.sum() + + # reorder the columns to have the groupby columns first + histogram_df = histogram_df.reset_index().loc[:, groupby + bin_edges_str] + + return histogram_df diff --git a/tests/unit_tests/pandas_postprocessing/test_histogram.py b/tests/unit_tests/pandas_postprocessing/test_histogram.py new file mode 100644 index 000000000..4e91a239a --- /dev/null +++ b/tests/unit_tests/pandas_postprocessing/test_histogram.py @@ -0,0 +1,122 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF 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. +from pandas import DataFrame + +from superset.utils.pandas_postprocessing import histogram + +data = DataFrame( + { + "group": ["A", "A", "B", "B", "A", "A", "B", "B", "A", "A"], + "a": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], + "b": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], + } +) + +bins = 5 + + +def test_histogram_no_groupby(): + data_with_no_groupings = DataFrame( + {"a": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], "b": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]} + ) + result = histogram(data_with_no_groupings, "a", [], bins) + assert result.shape == (1, bins) + assert result.columns.tolist() == ["1 - 2", "2 - 4", "4 - 6", "6 - 8", "8 - 10"] + assert result.values.tolist() == [[2, 2, 2, 2, 2]] + + +def test_histogram_with_groupby(): + result = histogram(data, "a", ["group"], bins) + assert result.shape == (2, bins + 1) + assert result.columns.tolist() == [ + "group", + "1 - 2", + "2 - 4", + "4 - 6", + "6 - 8", + "8 - 10", + ] + assert result.values.tolist() == [["A", 2, 0, 2, 0, 2], ["B", 0, 2, 0, 2, 0]] + + +def test_histogram_with_groupby_and_normalize(): + result = histogram(data, "a", ["group"], bins, normalize=True) + assert result.shape == (2, bins + 1) + assert result.columns.tolist() == [ + "group", + "1 - 2", + "2 - 4", + "4 - 6", + "6 - 8", + "8 - 10", + ] + assert result.values.tolist() == [ + ["A", 0.2, 0.0, 0.2, 0.0, 0.2], + ["B", 0.0, 0.2, 0.0, 0.2, 0.0], + ] + + +def test_histogram_with_groupby_and_cumulative(): + result = histogram(data, "a", ["group"], bins, cumulative=True) + assert result.shape == (2, bins + 1) + assert result.columns.tolist() == [ + "group", + "1 - 2", + "2 - 4", + "4 - 6", + "6 - 8", + "8 - 10", + ] + assert result.values.tolist() == [["A", 2, 2, 4, 4, 6], ["B", 0, 2, 2, 4, 4]] + + +def test_histogram_with_groupby_and_cumulative_and_normalize(): + result = histogram(data, "a", ["group"], bins, cumulative=True, normalize=True) + assert result.shape == (2, bins + 1) + assert result.columns.tolist() == [ + "group", + "1 - 2", + "2 - 4", + "4 - 6", + "6 - 8", + "8 - 10", + ] + assert result.values.tolist() == [ + [ + "A", + 0.06666666666666667, + 0.06666666666666667, + 0.13333333333333333, + 0.13333333333333333, + 0.2, + ], + [ + "B", + 0.0, + 0.06666666666666667, + 0.06666666666666667, + 0.13333333333333333, + 0.13333333333333333, + ], + ] + + +def test_histogram_with_non_numeric_column(): + try: + histogram(data, "b", ["group"], bins) + except ValueError as e: + assert str(e) == "The column 'b' must be numeric."