feat: Adds the ECharts Heatmap chart (#25353)

This commit is contained in:
Michael S. Molina
2024-03-28 16:16:17 -03:00
committed by GitHub
parent d69a1870a0
commit 546d48adbb
23 changed files with 929 additions and 24 deletions

View File

@@ -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"
}
}

View File

@@ -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 (
<Echart
refs={refs}
height={height}
width={width}
echartOptions={echartOptions}
/>
);
}

View File

@@ -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,
}),
],
},
]);
}

View File

@@ -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: (
<>
<div>
{t(
'Color will be shaded based the normalized (0% to 100%) value of a given cell against the other cells in the selected range: ',
)}
</div>
<ul>
<li>{t('x: values are normalized within each column')}</li>
<li>{t('y: values are normalized within each row')}</li>
<li>
{t(
'heatmap: values are normalized across the entire heatmap',
)}
</li>
</ul>
</>
),
},
},
],
],
},
{
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;

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

View File

@@ -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,
});
}
}

View File

@@ -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<string, any>[],
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 `
<div>
<div>${colnames[0]}: <b>${formattedX}</b></div>
<div>${colnames[1]}: <b>${formattedY}</b></div>
<div>${colnames[2]}: <b>${formattedValue}</b></div>
${
showPercentage
? `<div>% (${suffix}): <b>${valueFormatter(
percentage,
)}%</b></div>`
: ''
}
</div>`;
},
},
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,
};
}

View File

@@ -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<HeatmapFormData> {
formData: HeatmapFormData;
}
export type HeatmapTransformedProps = BaseTransformedProps<HeatmapFormData>;

View File

@@ -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';