feat: Adds the ECharts Histogram chart (#28652)
This commit is contained in:
parent
bc9eab9902
commit
896fe854dc
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -22,3 +22,4 @@ export { default as sharedControlComponents } from './components';
|
|||
export * from './components';
|
||||
export * from './customControls';
|
||||
export * from './mixins';
|
||||
export * from './dndControls';
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -35,9 +35,13 @@ export function formatSelectOptions<T extends Formattable>(
|
|||
* >> 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;
|
||||
|
|
|
|||
|
|
@ -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']),
|
||||
});
|
||||
});
|
||||
|
|
@ -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'],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<Echart
|
||||
refs={refs}
|
||||
height={height}
|
||||
width={width}
|
||||
echartOptions={echartOptions}
|
||||
eventHandlers={eventHandlers}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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)],
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
|
@ -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;
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 57 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 53 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 43 KiB |
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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<HistogramFormData> {
|
||||
formData: HistogramFormData;
|
||||
}
|
||||
|
||||
export type HistogramTransformedProps =
|
||||
BaseTransformedProps<HistogramFormData> & {
|
||||
onFocusedSeries: (index: number | undefined) => void;
|
||||
};
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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 =>
|
||||
|
|
|
|||
|
|
@ -74,6 +74,7 @@ interface ColumnSelectPopoverProps {
|
|||
label: string;
|
||||
isTemporal?: boolean;
|
||||
setDatasetModal?: Dispatch<SetStateAction<boolean>>;
|
||||
disabledTabs?: Set<string>;
|
||||
}
|
||||
|
||||
const getInitialColumnValues = (
|
||||
|
|
@ -102,6 +103,7 @@ const ColumnSelectPopover = ({
|
|||
onClose,
|
||||
setDatasetModal,
|
||||
setLabel,
|
||||
disabledTabs = new Set<'saved' | 'simple' | 'sqlExpression'>(),
|
||||
}: ColumnSelectPopoverProps) => {
|
||||
const datasourceType = useSelector<ExplorePageState, string | undefined>(
|
||||
state => state.explore.datasource.type,
|
||||
|
|
@ -299,7 +301,11 @@ const ColumnSelectPopover = ({
|
|||
width: ${width}px;
|
||||
`}
|
||||
>
|
||||
<Tabs.TabPane key="saved" tab={t('Saved')}>
|
||||
<Tabs.TabPane
|
||||
key="saved"
|
||||
tab={t('Saved')}
|
||||
disabled={disabledTabs.has('saved')}
|
||||
>
|
||||
{calculatedColumns.length > 0 ? (
|
||||
<FormItem label={savedExpressionsLabel}>
|
||||
<StyledSelect
|
||||
|
|
@ -375,7 +381,11 @@ const ColumnSelectPopover = ({
|
|||
/>
|
||||
)}
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane key="simple" tab={t('Simple')}>
|
||||
<Tabs.TabPane
|
||||
key="simple"
|
||||
tab={t('Simple')}
|
||||
disabled={disabledTabs.has('simple')}
|
||||
>
|
||||
{isTemporal && simpleColumns.length === 0 ? (
|
||||
<EmptyStateSmall
|
||||
image="empty.svg"
|
||||
|
|
@ -419,7 +429,11 @@ const ColumnSelectPopover = ({
|
|||
)}
|
||||
</Tabs.TabPane>
|
||||
|
||||
<Tabs.TabPane key="sqlExpression" tab={t('Custom SQL')}>
|
||||
<Tabs.TabPane
|
||||
key="sqlExpression"
|
||||
tab={t('Custom SQL')}
|
||||
disabled={disabledTabs.has('sqlExpression')}
|
||||
>
|
||||
<SQLEditor
|
||||
value={
|
||||
adhocColumn?.sqlExpression ||
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ interface ColumnSelectPopoverTriggerProps {
|
|||
closePopover?: () => void;
|
||||
children: React.ReactNode;
|
||||
isTemporal?: boolean;
|
||||
disabledTabs?: Set<string>;
|
||||
}
|
||||
|
||||
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}
|
||||
/>
|
||||
</ExplorePopoverContent>
|
||||
),
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ import { DndControlProps } from './types';
|
|||
export type DndColumnSelectProps = DndControlProps<QueryFormColumn> & {
|
||||
options: ColumnMeta[];
|
||||
isTemporal?: boolean;
|
||||
disabledTabs?: Set<string>;
|
||||
};
|
||||
|
||||
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}
|
||||
>
|
||||
<OptionWrapper
|
||||
key={idx}
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ import {
|
|||
EchartsTimeseriesStepChartPlugin,
|
||||
EchartsGraphChartPlugin,
|
||||
EchartsGaugeChartPlugin,
|
||||
EchartsHistogramChartPlugin,
|
||||
EchartsRadarChartPlugin,
|
||||
EchartsFunnelChartPlugin,
|
||||
EchartsTreemapChartPlugin,
|
||||
|
|
@ -160,6 +161,7 @@ export default class MainPreset extends Preset {
|
|||
key: 'waterfall',
|
||||
}),
|
||||
new EchartsHeatmapChartPlugin().configure({ key: 'heatmap_v2' }),
|
||||
new EchartsHistogramChartPlugin().configure({ key: 'histogram_v2' }),
|
||||
new SelectFilterPlugin().configure({ key: FilterPlugins.Select }),
|
||||
new RangeFilterPlugin().configure({ key: FilterPlugins.Range }),
|
||||
new TimeFilterPlugin().configure({ key: FilterPlugins.Time }),
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ from superset.utils.pandas_postprocessing.geography import (
|
|||
geohash_decode,
|
||||
geohash_encode,
|
||||
)
|
||||
from superset.utils.pandas_postprocessing.histogram import histogram
|
||||
from superset.utils.pandas_postprocessing.pivot import pivot
|
||||
from superset.utils.pandas_postprocessing.prophet import prophet
|
||||
from superset.utils.pandas_postprocessing.rank import rank
|
||||
|
|
@ -49,6 +50,7 @@ __all__ = [
|
|||
"geohash_encode",
|
||||
"geohash_decode",
|
||||
"geodetic_parse",
|
||||
"histogram",
|
||||
"pivot",
|
||||
"prophet",
|
||||
"rank",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,87 @@
|
|||
# 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 __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
from pandas import DataFrame, Series
|
||||
|
||||
|
||||
# pylint: disable=too-many-arguments
|
||||
def histogram(
|
||||
df: DataFrame,
|
||||
column: str,
|
||||
groupby: list[str] | None,
|
||||
bins: int = 5,
|
||||
cumulative: bool = False,
|
||||
normalize: bool = False,
|
||||
) -> 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
|
||||
|
|
@ -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."
|
||||
Loading…
Reference in New Issue