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 f39d649f886..c7151dafd45 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 @@ -28,4 +28,5 @@ export { contributionOperator } from './contributionOperator'; export { prophetOperator } from './prophetOperator'; export { boxplotOperator } from './boxplotOperator'; export { flattenOperator } from './flattenOperator'; +export { rankOperator } from './rankOperator'; export * from './utils'; diff --git a/superset-frontend/packages/superset-ui-chart-controls/src/operators/rankOperator.ts b/superset-frontend/packages/superset-ui-chart-controls/src/operators/rankOperator.ts new file mode 100644 index 00000000000..2f9da25d329 --- /dev/null +++ b/superset-frontend/packages/superset-ui-chart-controls/src/operators/rankOperator.ts @@ -0,0 +1,30 @@ +/** + * 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 { PostProcessingRank } from '@superset-ui/core'; +import { PostProcessingFactory } from './types'; + +/* eslint-disable @typescript-eslint/no-unused-vars */ +export const rankOperator: PostProcessingFactory = ( + formData, + queryObject, + options, +) => ({ + operation: 'rank', + options, +}); diff --git a/superset-frontend/packages/superset-ui-chart-controls/src/operators/types.ts b/superset-frontend/packages/superset-ui-chart-controls/src/operators/types.ts index 34f632ff8f3..0c5285a2a1e 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/src/operators/types.ts +++ b/superset-frontend/packages/superset-ui-chart-controls/src/operators/types.ts @@ -19,5 +19,5 @@ import { QueryFormData, QueryObject } from '@superset-ui/core'; export interface PostProcessingFactory { - (formData: QueryFormData, queryObject: QueryObject): T; + (formData: QueryFormData, queryObject: QueryObject, options?: any): T; } diff --git a/superset-frontend/packages/superset-ui-chart-controls/test/operators/rankOperator.test.ts b/superset-frontend/packages/superset-ui-chart-controls/test/operators/rankOperator.test.ts new file mode 100644 index 00000000000..91d67b59a28 --- /dev/null +++ b/superset-frontend/packages/superset-ui-chart-controls/test/operators/rankOperator.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 { QueryObject, SqlaFormData } from '@superset-ui/core'; +import { rankOperator } from '@superset-ui/chart-controls'; + +const formData: SqlaFormData = { + x_axis: 'dttm', + metrics: ['sales'], + groupby: ['department'], + time_range: '2015 : 2016', + granularity: 'month', + datasource: 'foo', + viz_type: 'table', + truncate_metric: true, +}; +const queryObject: QueryObject = { + is_timeseries: true, + metrics: ['sales'], + columns: ['department'], + time_range: '2015 : 2016', + granularity: 'month', + post_processing: [], +}; + +test('should add rankOperator', () => { + const options = { metric: 'sales', group_by: 'department' }; + expect(rankOperator(formData, queryObject, options)).toEqual({ + operation: 'rank', + options, + }); +}); 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 e32eda6a90a..3b409413322 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 @@ -224,6 +224,15 @@ export type PostProcessingFlatten = | _PostProcessingFlatten | DefaultPostProcessing; +interface _PostProcessingRank { + operation: 'rank'; + options?: { + metric: string; + group_by: string | null; + }; +} +export type PostProcessingRank = _PostProcessingRank | DefaultPostProcessing; + /** * Parameters for chart data postprocessing. * See superset/utils/pandas_processing.py. @@ -241,7 +250,8 @@ export type PostProcessingRule = | PostProcessingSort | PostProcessingResample | PostProcessingRename - | PostProcessingFlatten; + | PostProcessingFlatten + | PostProcessingRank; export function isPostProcessingAggregation( rule?: PostProcessingRule, diff --git a/superset-frontend/plugins/legacy-plugin-chart-heatmap/src/index.js b/superset-frontend/plugins/legacy-plugin-chart-heatmap/src/index.js index 43d5b3eda0c..3779c0d03ec 100644 --- a/superset-frontend/plugins/legacy-plugin-chart-heatmap/src/index.js +++ b/superset-frontend/plugins/legacy-plugin-chart-heatmap/src/index.js @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -import { t, ChartMetadata, ChartPlugin } from '@superset-ui/core'; +import { t, ChartMetadata, ChartPlugin, ChartLabel } from '@superset-ui/core'; import transformProps from './transformProps'; import transportation from './images/transportation.jpg'; import channels from './images/channels.jpg'; @@ -35,7 +35,8 @@ const metadata = new ChartMetadata({ { url: channels, caption: t('Relationships between community channels') }, { url: employment, caption: t('Employment and education') }, ], - name: t('Heatmap'), + label: ChartLabel.DEPRECATED, + name: t('Heatmap (legacy)'), tags: [ t('Business'), t('Intensity'), @@ -43,11 +44,15 @@ const metadata = new ChartMetadata({ t('Density'), t('Predictive'), t('Single Metric'), + t('Deprecated'), ], thumbnail, useLegacyApi: true, }); +/** + * @deprecated in version 4.0. + */ export default class HeatmapChartPlugin extends ChartPlugin { constructor() { super({ diff --git a/superset-frontend/plugins/plugin-chart-echarts/package.json b/superset-frontend/plugins/plugin-chart-echarts/package.json index c91e52bdf3a..6c0e5fa63f7 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/package.json +++ b/superset-frontend/plugins/plugin-chart-echarts/package.json @@ -2,6 +2,20 @@ "name": "@superset-ui/plugin-chart-echarts", "version": "0.18.25", "description": "Superset Chart - Echarts", + "keywords": [ + "superset" + ], + "homepage": "https://github.com/apache/superset/tree/master/superset-frontend/plugins/plugin-chart-echarts#readme", + "bugs": { + "url": "https://github.com/apache/superset/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/apache/superset.git", + "directory": "superset-frontend/plugins/plugin-chart-echarts" + }, + "license": "Apache-2.0", + "author": "Superset", "sideEffects": false, "main": "lib/index.js", "module": "esm/index.js", @@ -9,23 +23,6 @@ "esm", "lib" ], - "repository": { - "type": "git", - "url": "https://github.com/apache/superset.git", - "directory": "superset-frontend/plugins/plugin-chart-echarts" - }, - "keywords": [ - "superset" - ], - "author": "Superset", - "license": "Apache-2.0", - "bugs": { - "url": "https://github.com/apache/superset/issues" - }, - "homepage": "https://github.com/apache/superset/tree/master/superset-frontend/plugins/plugin-chart-echarts#readme", - "publishConfig": { - "access": "public" - }, "dependencies": { "d3-array": "^1.2.0", "echarts": "^5.4.1", @@ -35,6 +32,10 @@ "peerDependencies": { "@superset-ui/chart-controls": "*", "@superset-ui/core": "*", + "memoize-one": "*", "react": "^16.13.1" + }, + "publishConfig": { + "access": "public" } } diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Heatmap/Heatmap.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Heatmap/Heatmap.tsx new file mode 100644 index 00000000000..555b9a63a3e --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Heatmap/Heatmap.tsx @@ -0,0 +1,33 @@ +/** + * 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 { HeatmapTransformedProps } from './types'; +import Echart from '../components/Echart'; + +export default function Heatmap(props: HeatmapTransformedProps) { + const { height, width, echartOptions, refs } = props; + return ( + + ); +} diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Heatmap/buildQuery.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Heatmap/buildQuery.ts new file mode 100644 index 00000000000..2d1ee869eb9 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Heatmap/buildQuery.ts @@ -0,0 +1,68 @@ +/** + * 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, + QueryFormOrderBy, + buildQueryContext, + ensureIsArray, + getColumnLabel, + getMetricLabel, + getXAxisColumn, +} from '@superset-ui/core'; +import { rankOperator } from '@superset-ui/chart-controls'; +import { HeatmapFormData } from './types'; + +export default function buildQuery(formData: HeatmapFormData) { + const { groupby, normalize_across, sort_x_axis, sort_y_axis, x_axis } = + formData; + const metric = getMetricLabel(formData.metric); + const columns = [ + ...ensureIsArray(getXAxisColumn(formData)), + ...ensureIsArray(groupby), + ]; + const orderby: QueryFormOrderBy[] = [ + [ + sort_x_axis.includes('value') ? metric : columns[0], + sort_x_axis.includes('asc'), + ], + [ + sort_y_axis.includes('value') ? metric : columns[1], + sort_y_axis.includes('asc'), + ], + ]; + const group_by = + normalize_across === 'x' + ? getColumnLabel(x_axis) + : normalize_across === 'y' + ? getColumnLabel(groupby as unknown as QueryFormColumn) + : undefined; + return buildQueryContext(formData, baseQueryObject => [ + { + ...baseQueryObject, + columns, + orderby, + post_processing: [ + rankOperator(formData, baseQueryObject, { + metric, + group_by, + }), + ], + }, + ]); +} diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Heatmap/controlPanel.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Heatmap/controlPanel.tsx new file mode 100644 index 00000000000..28356e64410 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Heatmap/controlPanel.tsx @@ -0,0 +1,304 @@ +/** + * 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 { t, validateNonEmpty } from '@superset-ui/core'; +import { + ControlPanelConfig, + formatSelectOptionsForRange, + getStandardizedControls, +} from '@superset-ui/chart-controls'; + +const sortAxisChoices = [ + ['alpha_asc', t('Axis ascending')], + ['alpha_desc', t('Axis descending')], + ['value_asc', t('Metric ascending')], + ['value_desc', t('Metric descending')], +]; + +const config: ControlPanelConfig = { + controlPanelSections: [ + { + label: t('Query'), + expanded: true, + controlSetRows: [ + ['x_axis'], + ['time_grain_sqla'], + ['groupby'], + ['metric'], + ['adhoc_filters'], + ['row_limit'], + [ + { + name: 'sort_x_axis', + config: { + type: 'SelectControl', + label: t('Sort X Axis'), + choices: sortAxisChoices, + renderTrigger: false, + clearable: false, + default: 'alpha_asc', + }, + }, + ], + [ + { + name: 'sort_y_axis', + config: { + type: 'SelectControl', + label: t('Sort Y Axis'), + choices: sortAxisChoices, + renderTrigger: false, + clearable: false, + default: 'alpha_asc', + }, + }, + ], + [ + { + name: 'normalize_across', + config: { + type: 'SelectControl', + label: t('Normalize Across'), + choices: [ + ['heatmap', t('heatmap')], + ['x', t('x')], + ['y', t('y')], + ], + default: 'heatmap', + renderTrigger: false, + description: ( + <> +
+ {t( + 'Color will be shaded based the normalized (0% to 100%) value of a given cell against the other cells in the selected range: ', + )} +
+
    +
  • {t('x: values are normalized within each column')}
  • +
  • {t('y: values are normalized within each row')}
  • +
  • + {t( + 'heatmap: values are normalized across the entire heatmap', + )} +
  • +
+ + ), + }, + }, + ], + ], + }, + { + label: t('Chart Options'), + expanded: true, + controlSetRows: [ + [ + { + name: 'legend_type', + config: { + type: 'SelectControl', + label: t('Legend Type'), + renderTrigger: true, + choices: [ + ['continuous', t('Continuous')], + ['piecewise', t('Piecewise')], + ], + default: 'continuous', + clearable: false, + }, + }, + ], + ['linear_color_scheme'], + [ + { + name: 'xscale_interval', + config: { + type: 'SelectControl', + label: t('XScale Interval'), + renderTrigger: true, + choices: [[-1, t('Auto')]].concat( + formatSelectOptionsForRange(1, 50), + ), + default: -1, + clearable: false, + description: t( + 'Number of steps to take between ticks when displaying the X scale', + ), + }, + }, + ], + [ + { + name: 'yscale_interval', + config: { + type: 'SelectControl', + label: t('YScale Interval'), + choices: [[-1, t('Auto')]].concat( + formatSelectOptionsForRange(1, 50), + ), + default: -1, + clearable: false, + renderTrigger: true, + description: t( + 'Number of steps to take between ticks when displaying the Y scale', + ), + }, + }, + ], + [ + { + name: 'left_margin', + config: { + type: 'SelectControl', + freeForm: true, + clearable: false, + label: t('Left Margin'), + choices: [ + ['auto', t('Auto')], + [50, '50'], + [75, '75'], + [100, '100'], + [125, '125'], + [150, '150'], + [200, '200'], + ], + default: 'auto', + renderTrigger: true, + description: t( + 'Left margin, in pixels, allowing for more room for axis labels', + ), + }, + }, + ], + [ + { + name: 'bottom_margin', + config: { + type: 'SelectControl', + clearable: false, + freeForm: true, + label: t('Bottom Margin'), + choices: [ + ['auto', t('Auto')], + [50, '50'], + [75, '75'], + [100, '100'], + [125, '125'], + [150, '150'], + [200, '200'], + ], + default: 'auto', + renderTrigger: true, + description: t( + 'Bottom margin, in pixels, allowing for more room for axis labels', + ), + }, + }, + ], + [ + { + name: 'value_bounds', + config: { + type: 'BoundsControl', + label: t('Value bounds'), + renderTrigger: true, + default: [null, null], + description: t('Hard value bounds applied for color coding.'), + }, + }, + ], + ['y_axis_format'], + ['x_axis_time_format'], + ['currency_format'], + [ + { + name: 'show_legend', + config: { + type: 'CheckboxControl', + label: t('Legend'), + renderTrigger: true, + default: true, + description: t('Whether to display the legend (toggles)'), + }, + }, + ], + [ + { + name: 'show_percentage', + config: { + type: 'CheckboxControl', + label: t('Show percentage'), + renderTrigger: true, + description: t( + 'Whether to include the percentage in the tooltip', + ), + default: true, + }, + }, + ], + [ + { + name: 'show_values', + config: { + type: 'CheckboxControl', + label: t('Show Values'), + renderTrigger: true, + default: false, + description: t( + 'Whether to display the numerical values within the cells', + ), + }, + }, + ], + [ + { + name: 'normalized', + config: { + type: 'CheckboxControl', + label: t('Normalized'), + renderTrigger: true, + description: t( + 'Whether to apply a normal distribution based on rank on the color scale', + ), + default: false, + }, + }, + ], + ], + }, + ], + controlOverrides: { + groupby: { + label: t('Y-Axis'), + description: t('Dimension to use on y-axis.'), + multi: false, + validators: [validateNonEmpty], + }, + y_axis_format: { + label: t('Value Format'), + }, + }, + formDataOverrides: formData => ({ + ...formData, + metric: getStandardizedControls().shiftMetric(), + }), +}; + +export default config; diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Heatmap/images/example1.png b/superset-frontend/plugins/plugin-chart-echarts/src/Heatmap/images/example1.png new file mode 100644 index 00000000000..7d7339b316b Binary files /dev/null and b/superset-frontend/plugins/plugin-chart-echarts/src/Heatmap/images/example1.png differ diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Heatmap/images/example2.png b/superset-frontend/plugins/plugin-chart-echarts/src/Heatmap/images/example2.png new file mode 100644 index 00000000000..ea4b1ec1633 Binary files /dev/null and b/superset-frontend/plugins/plugin-chart-echarts/src/Heatmap/images/example2.png differ diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Heatmap/images/example3.png b/superset-frontend/plugins/plugin-chart-echarts/src/Heatmap/images/example3.png new file mode 100644 index 00000000000..c161192fa04 Binary files /dev/null and b/superset-frontend/plugins/plugin-chart-echarts/src/Heatmap/images/example3.png differ diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Heatmap/images/thumbnail.png b/superset-frontend/plugins/plugin-chart-echarts/src/Heatmap/images/thumbnail.png new file mode 100644 index 00000000000..2993ef351f6 Binary files /dev/null and b/superset-frontend/plugins/plugin-chart-echarts/src/Heatmap/images/thumbnail.png differ diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Heatmap/index.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Heatmap/index.ts new file mode 100644 index 00000000000..e9b2c754417 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Heatmap/index.ts @@ -0,0 +1,55 @@ +/** + * 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 { t, ChartMetadata, ChartPlugin } from '@superset-ui/core'; +import transformProps from './transformProps'; +import buildQuery from './buildQuery'; +import example1 from './images/example1.png'; +import example2 from './images/example2.png'; +import example3 from './images/example3.png'; +import thumbnail from './images/thumbnail.png'; +import controlPanel from './controlPanel'; + +const metadata = new ChartMetadata({ + category: t('Correlation'), + description: t( + 'Visualize a related metric across pairs of groups. Heatmaps excel at showcasing the correlation or strength between two groups. Color is used to emphasize the strength of the link between each pair of groups.', + ), + exampleGallery: [{ url: example1 }, { url: example2 }, { url: example3 }], + name: t('Heatmap'), + tags: [ + t('Business'), + t('Intensity'), + t('Density'), + t('Single Metric'), + t('ECharts'), + ], + thumbnail, +}); + +export default class EchartsHeatmapChartPlugin extends ChartPlugin { + constructor() { + super({ + buildQuery, + loadChart: () => import('./Heatmap'), + metadata, + transformProps, + controlPanel, + }); + } +} diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Heatmap/transformProps.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Heatmap/transformProps.ts new file mode 100644 index 00000000000..832b8687791 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Heatmap/transformProps.ts @@ -0,0 +1,243 @@ +/** + * 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, + QueryFormColumn, + getColumnLabel, + getMetricLabel, + getSequentialSchemeRegistry, + getTimeFormatter, + getValueFormatter, +} from '@superset-ui/core'; +import memoizeOne from 'memoize-one'; +import { maxBy, minBy } from 'lodash'; +import { EChartsOption, HeatmapSeriesOption } from 'echarts'; +import { CallbackDataParams } from 'echarts/types/src/util/types'; +import { HeatmapChartProps, HeatmapTransformedProps } from './types'; +import { getDefaultTooltip } from '../utils/tooltip'; +import { Refs } from '../types'; +import { parseAxisBound } from '../utils/controls'; +import { NULL_STRING } from '../constants'; + +// Calculated totals per x and y categories plus total +const calculateTotals = memoizeOne( + ( + data: Record[], + xAxis: string, + groupby: string, + metric: string, + ) => + data.reduce( + (acc, row) => { + const value = row[metric]; + if (typeof value !== 'number') { + return acc; + } + const x = row[xAxis] as string; + const y = row[groupby] as string; + const xTotal = acc.x[x] || 0; + const yTotal = acc.y[y] || 0; + return { + x: { ...acc.x, [x]: xTotal + value }, + y: { ...acc.y, [y]: yTotal + value }, + total: acc.total + value, + }; + }, + { x: {}, y: {}, total: 0 }, + ), +); + +export default function transformProps( + chartProps: HeatmapChartProps, +): HeatmapTransformedProps { + const refs: Refs = {}; + const { width, height, formData, queriesData, datasource } = chartProps; + const { + bottomMargin, + xAxis, + groupby, + linearColorScheme, + leftMargin, + legendType = 'continuous', + metric, + normalizeAcross, + normalized, + showLegend, + showPercentage, + showValues, + xscaleInterval, + yscaleInterval, + valueBounds, + yAxisFormat, + xAxisTimeFormat, + currencyFormat, + } = formData; + const metricLabel = getMetricLabel(metric); + const xAxisLabel = getColumnLabel(xAxis); + // groupby is overridden to be a single value + const yAxisLabel = getColumnLabel(groupby as unknown as QueryFormColumn); + const { data, colnames, coltypes } = queriesData[0]; + const { columnFormats = {}, currencyFormats = {} } = datasource; + const colorColumn = normalized ? 'rank' : metricLabel; + const colors = getSequentialSchemeRegistry().get(linearColorScheme)?.colors; + const getAxisFormatter = + (colType: GenericDataType) => (value: number | string) => { + if (colType === GenericDataType.Temporal) { + if (typeof value === 'string') { + return getTimeFormatter(xAxisTimeFormat)(Number.parseInt(value, 10)); + } + return getTimeFormatter(xAxisTimeFormat)(value); + } + return String(value); + }; + + const xAxisFormatter = getAxisFormatter(coltypes[0]); + const yAxisFormatter = getAxisFormatter(coltypes[1]); + const valueFormatter = getValueFormatter( + metric, + currencyFormats, + columnFormats, + yAxisFormat, + currencyFormat, + ); + + let [min, max] = (valueBounds || []).map(parseAxisBound); + if (min === undefined) { + min = minBy(data, row => row[colorColumn])?.[colorColumn] as number; + } + if (max === undefined) { + max = maxBy(data, row => row[colorColumn])?.[colorColumn] as number; + } + + const series: HeatmapSeriesOption[] = [ + { + name: metricLabel, + type: 'heatmap', + data: data.map(row => + colnames.map(col => { + const value = row[col]; + if (!value) { + return NULL_STRING; + } + if (typeof value === 'boolean') { + return String(value); + } + return value; + }), + ), + label: { + show: showValues, + formatter: (params: CallbackDataParams) => + valueFormatter(params.value[2]), + }, + }, + ]; + + const echartOptions: EChartsOption = { + grid: { + containLabel: true, + bottom: bottomMargin, + left: leftMargin, + }, + series, + tooltip: { + ...getDefaultTooltip(refs), + formatter: (params: CallbackDataParams) => { + const totals = calculateTotals( + data, + xAxisLabel, + yAxisLabel, + metricLabel, + ); + const x = params.value[0]; + const y = params.value[1]; + const value = params.value[2]; + const formattedX = xAxisFormatter(x); + const formattedY = yAxisFormatter(y); + const formattedValue = valueFormatter(value); + let percentage = 0; + let suffix = 'heatmap'; + if (typeof value === 'number') { + if (normalizeAcross === 'x') { + percentage = (value / totals.x[x]) * 100; + suffix = formattedX; + } else if (normalizeAcross === 'y') { + percentage = (value / totals.y[y]) * 100; + suffix = formattedY; + } else { + percentage = (value / totals.total) * 100; + suffix = 'heatmap'; + } + } + return ` +
+
${colnames[0]}: ${formattedX}
+
${colnames[1]}: ${formattedY}
+
${colnames[2]}: ${formattedValue}
+ ${ + showPercentage + ? `
% (${suffix}): ${valueFormatter( + percentage, + )}%
` + : '' + } +
`; + }, + }, + visualMap: { + type: legendType, + min, + max, + calculable: true, + orient: 'horizontal', + right: 0, + top: 0, + itemHeight: legendType === 'continuous' ? 300 : 14, + itemWidth: 15, + formatter: min => valueFormatter(min as number), + inRange: { + color: colors, + }, + show: showLegend, + // By default, ECharts uses the last dimension which is rank + dimension: normalized ? 3 : 2, + }, + xAxis: { + type: 'category', + axisLabel: { + formatter: xAxisFormatter, + interval: xscaleInterval === -1 ? 'auto' : xscaleInterval - 1, + }, + }, + yAxis: { + type: 'category', + axisLabel: { + formatter: yAxisFormatter, + interval: yscaleInterval === -1 ? 'auto' : yscaleInterval - 1, + }, + }, + }; + return { + refs, + echartOptions, + width, + height, + formData, + }; +} diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Heatmap/types.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Heatmap/types.ts new file mode 100644 index 00000000000..8ec98470329 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Heatmap/types.ts @@ -0,0 +1,53 @@ +/** + * 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 { + Currency, + QueryFormColumn, + QueryFormData, + QueryFormMetric, +} from '@superset-ui/core'; +import { BaseChartProps, BaseTransformedProps } from '../types'; + +export interface HeatmapFormData extends QueryFormData { + bottomMargin: string; + currencyFormat?: Currency; + leftMargin: string; + legendType: 'continuous' | 'piecewise'; + linearColorScheme?: string; + metric: QueryFormMetric; + normalizeAcross: 'heatmap' | 'x' | 'y'; + normalized?: boolean; + showLegend?: boolean; + showPercentage?: boolean; + showValues?: boolean; + sortXAxis: string; + sortYAxis: string; + timeFormat?: string; + xAxis: QueryFormColumn; + xscaleInterval: number; + valueBounds: [number | undefined | null, number | undefined | null]; + yAxisFormat?: string; + yscaleInterval: number; +} + +export interface HeatmapChartProps extends BaseChartProps { + formData: HeatmapFormData; +} + +export type HeatmapTransformedProps = BaseTransformedProps; diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/index.ts b/superset-frontend/plugins/plugin-chart-echarts/src/index.ts index 2e192031384..f3bee620955 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/index.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/index.ts @@ -31,6 +31,7 @@ export { default as EchartsGaugeChartPlugin } from './Gauge'; export { default as EchartsRadarChartPlugin } from './Radar'; export { default as EchartsFunnelChartPlugin } from './Funnel'; export { default as EchartsTreeChartPlugin } from './Tree'; +export { default as EchartsHeatmapChartPlugin } from './Heatmap'; export { default as EchartsTreemapChartPlugin } from './Treemap'; export { BigNumberChartPlugin, @@ -51,6 +52,7 @@ export { default as RadarTransformProps } from './Radar/transformProps'; export { default as TimeseriesTransformProps } from './Timeseries/transformProps'; export { default as TreeTransformProps } from './Tree/transformProps'; export { default as TreemapTransformProps } from './Treemap/transformProps'; +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'; diff --git a/superset-frontend/src/explore/components/controls/BoundsControl.tsx b/superset-frontend/src/explore/components/controls/BoundsControl.tsx index 97fd26e283e..03ab6037910 100644 --- a/superset-frontend/src/explore/components/controls/BoundsControl.tsx +++ b/superset-frontend/src/explore/components/controls/BoundsControl.tsx @@ -19,7 +19,7 @@ import React, { useEffect, useRef, useState } from 'react'; import { InputNumber } from 'src/components/Input'; import { t, styled } from '@superset-ui/core'; -import { debounce } from 'lodash'; +import { debounce, parseInt } from 'lodash'; import ControlHeader from 'src/explore/components/ControlHeader'; type ValueType = (number | null)[]; @@ -43,8 +43,16 @@ const MaxInput = styled(InputNumber)` margin-left: ${({ theme }) => theme.gridUnit}px; `; -const parseNumber = (value: undefined | number | string | null) => - value === null || Number.isNaN(Number(value)) ? null : Number(value); +const parseNumber = (value: undefined | number | string | null) => { + if ( + value === null || + value === undefined || + (typeof value === 'string' && Number.isNaN(parseInt(value))) + ) { + return null; + } + return Number(value); +}; export default function BoundsControl({ onChange = () => {}, diff --git a/superset-frontend/src/explore/components/controls/VizTypeControl/VizTypeGallery.tsx b/superset-frontend/src/explore/components/controls/VizTypeControl/VizTypeGallery.tsx index 4905e9a3337..ac84bd260c6 100644 --- a/superset-frontend/src/explore/components/controls/VizTypeControl/VizTypeGallery.tsx +++ b/superset-frontend/src/explore/components/controls/VizTypeControl/VizTypeGallery.tsx @@ -88,6 +88,7 @@ const DEFAULT_ORDER = [ 'time_pivot', 'deck_arc', 'heatmap', + 'heatmap_v2', 'deck_grid', 'deck_screengrid', 'treemap_v2', diff --git a/superset-frontend/src/visualizations/presets/MainPreset.js b/superset-frontend/src/visualizations/presets/MainPreset.js index f77f8c037b2..1d447228f98 100644 --- a/superset-frontend/src/visualizations/presets/MainPreset.js +++ b/superset-frontend/src/visualizations/presets/MainPreset.js @@ -67,6 +67,7 @@ import { EchartsBubbleChartPlugin, EchartsWaterfallChartPlugin, BigNumberPeriodOverPeriodChartPlugin, + EchartsHeatmapChartPlugin, } from '@superset-ui/plugin-chart-echarts'; import { SelectFilterPlugin, @@ -158,6 +159,7 @@ export default class MainPreset extends Preset { new EchartsWaterfallChartPlugin().configure({ key: 'waterfall', }), + new EchartsHeatmapChartPlugin().configure({ key: 'heatmap_v2' }), new SelectFilterPlugin().configure({ key: FilterPlugins.Select }), new RangeFilterPlugin().configure({ key: FilterPlugins.Range }), new TimeFilterPlugin().configure({ key: FilterPlugins.Time }), diff --git a/superset/utils/pandas_postprocessing/__init__.py b/superset/utils/pandas_postprocessing/__init__.py index e66a52f6552..6d6c833e64c 100644 --- a/superset/utils/pandas_postprocessing/__init__.py +++ b/superset/utils/pandas_postprocessing/__init__.py @@ -28,6 +28,7 @@ from superset.utils.pandas_postprocessing.geography import ( ) from superset.utils.pandas_postprocessing.pivot import pivot from superset.utils.pandas_postprocessing.prophet import prophet +from superset.utils.pandas_postprocessing.rank import rank from superset.utils.pandas_postprocessing.rename import rename from superset.utils.pandas_postprocessing.resample import resample from superset.utils.pandas_postprocessing.rolling import rolling @@ -50,6 +51,7 @@ __all__ = [ "geodetic_parse", "pivot", "prophet", + "rank", "rename", "resample", "rolling", diff --git a/superset/utils/pandas_postprocessing/rank.py b/superset/utils/pandas_postprocessing/rank.py new file mode 100644 index 00000000000..7b69e80089f --- /dev/null +++ b/superset/utils/pandas_postprocessing/rank.py @@ -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 limitations +# under the License. +from __future__ import annotations + +import pandas as pd + + +def rank( + df: pd.DataFrame, + metric: str, + group_by: str | None = None, +) -> pd.DataFrame: + """ + Calculates the rank of a metric within a group. + + :param df: N-dimensional DataFrame. + :param metric: The metric to rank. + :param group_by: The column to group by. + :return: a flat DataFrame + """ + if group_by: + gb = df.groupby(group_by, group_keys=False) + df["rank"] = gb.apply(lambda x: x[metric].rank(pct=True)) + else: + df["rank"] = df[metric].rank(pct=True) + return df