feat(plugin-chart-echarts): supports sunburst chart v2 [WIP] (#21625)

Co-authored-by: Michael S. Molina <michael.s.molina@gmail.com>
This commit is contained in:
Stephen Liu
2023-01-17 05:10:28 +08:00
committed by GitHub
parent d2a355b2fb
commit b53941fb3e
14 changed files with 1266 additions and 100 deletions

View File

@@ -0,0 +1,126 @@
/**
* 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, { useCallback } from 'react';
import { BinaryQueryObjectFilterClause } from '@superset-ui/core';
import { SunburstTransformedProps } from './types';
import Echart from '../components/Echart';
import { EventHandlers, TreePathInfo } from '../types';
export const extractTreePathInfo = (treePathInfo: TreePathInfo[] | undefined) =>
(treePathInfo ?? [])
.map(pathInfo => pathInfo?.name || '')
.filter(path => path !== '');
export default function EchartsSunburst(props: SunburstTransformedProps) {
const {
height,
width,
echartOptions,
setDataMask,
labelMap,
selectedValues,
formData,
onContextMenu,
refs,
} = props;
const { emitFilter, columns } = formData;
const handleChange = useCallback(
(values: string[]) => {
if (!emitFilter) {
return;
}
const labels = values.map(value => labelMap[value]);
setDataMask({
extraFormData: {
filters:
values.length === 0 || !columns
? []
: columns.map((col, idx) => {
const val = labels.map(v => v[idx]);
if (val === null || val === undefined)
return {
col,
op: 'IS NULL',
};
return {
col,
op: 'IN',
val: val as (string | number | boolean)[],
};
}),
},
filterState: {
value: labels.length ? labels : null,
selectedValues: values.length ? values : null,
},
});
},
[emitFilter, setDataMask, columns, labelMap],
);
const eventHandlers: EventHandlers = {
click: props => {
const { treePathInfo } = props;
const treePath = extractTreePathInfo(treePathInfo);
const name = treePath.join(',');
const values = Object.values(selectedValues);
if (values.includes(name)) {
handleChange(values.filter(v => v !== name));
} else {
handleChange([name]);
}
},
contextmenu: eventParams => {
if (onContextMenu) {
eventParams.event.stop();
const treePath = extractTreePathInfo(eventParams.treePathInfo);
if (treePath.length > 0) {
const pointerEvent = eventParams.event.event;
const filters: BinaryQueryObjectFilterClause[] = [];
if (columns) {
treePath.forEach((path, i) =>
filters.push({
col: columns[i],
op: '==',
val: path,
formattedVal: path,
}),
);
}
onContextMenu(pointerEvent.clientX, pointerEvent.clientY, filters);
}
}
},
};
return (
<Echart
refs={refs}
height={height}
width={width}
echartOptions={echartOptions}
eventHandlers={eventHandlers}
selectedValues={selectedValues}
/>
);
}

View File

@@ -0,0 +1,29 @@
/**
* 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, QueryFormData } from '@superset-ui/core';
export default function buildQuery(formData: QueryFormData) {
const { metric, sort_by_metric } = formData;
return buildQueryContext(formData, baseQueryObject => [
{
...baseQueryObject,
...(sort_by_metric && { orderby: [[metric, false]] }),
},
]);
}

View File

@@ -0,0 +1,207 @@
/**
* 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 } from '@superset-ui/core';
import {
ControlPanelConfig,
ControlPanelsContainerProps,
D3_FORMAT_DOCS,
D3_FORMAT_OPTIONS,
D3_TIME_FORMAT_OPTIONS,
emitFilterControl,
getStandardizedControls,
sections,
} from '@superset-ui/chart-controls';
import { DEFAULT_FORM_DATA } from './types';
const { labelType, numberFormat, showLabels } = DEFAULT_FORM_DATA;
const config: ControlPanelConfig = {
controlPanelSections: [
sections.legacyRegularTime,
{
label: t('Query'),
expanded: true,
controlSetRows: [
['columns'],
['metric'],
['secondary_metric'],
['adhoc_filters'],
emitFilterControl,
['row_limit'],
[
{
name: 'sort_by_metric',
config: {
type: 'CheckboxControl',
label: t('Sort by metric'),
description: t(
'Whether to sort results by the selected metric in descending order.',
),
},
},
],
],
},
{
label: t('Chart Options'),
expanded: true,
controlSetRows: [
['color_scheme'],
['linear_color_scheme'],
[<div className="section-header">{t('Labels')}</div>],
[
{
name: 'show_labels',
config: {
type: 'CheckboxControl',
label: t('Show Labels'),
renderTrigger: true,
default: showLabels,
description: t('Whether to display the labels.'),
},
},
],
[
{
name: 'show_labels_threshold',
config: {
type: 'TextControl',
label: t('Percentage threshold'),
renderTrigger: true,
isFloat: true,
default: 5,
description: t(
'Minimum threshold in percentage points for showing labels.',
),
},
},
],
[
{
name: 'show_total',
config: {
type: 'CheckboxControl',
label: t('Show Total'),
default: false,
renderTrigger: true,
description: t('Whether to display the aggregate count'),
},
},
],
[
{
name: 'label_type',
config: {
type: 'SelectControl',
label: t('Label Type'),
default: labelType,
renderTrigger: true,
choices: [
['key', t('Category Name')],
['value', t('Value')],
['key_value', t('Category and Value')],
],
description: t('What should be shown on the label?'),
},
},
],
[
{
name: 'number_format',
config: {
type: 'SelectControl',
freeForm: true,
label: t('Number format'),
renderTrigger: true,
default: numberFormat,
choices: D3_FORMAT_OPTIONS,
description: `${t(
'D3 format syntax: https://github.com/d3/d3-format',
)} ${t('Only applies when "Label Type" is set to show values.')}`,
},
},
],
[
{
name: 'date_format',
config: {
type: 'SelectControl',
freeForm: true,
label: t('Date format'),
renderTrigger: true,
choices: D3_TIME_FORMAT_OPTIONS,
default: 'smart_date',
description: D3_FORMAT_DOCS,
},
},
],
],
},
],
controlOverrides: {
metric: {
label: t('Primary Metric'),
description: t(
'The primary metric is used to define the arc segment sizes',
),
},
secondary_metric: {
label: t('Secondary Metric'),
default: null,
description: t(
'[optional] this secondary metric is used to ' +
'define the color as a ratio against the primary metric. ' +
'When omitted, the color is categorical and based on labels',
),
},
color_scheme: {
description: t(
'When only a primary metric is provided, a categorical color scale is used.',
),
visibility: ({ controls }: ControlPanelsContainerProps) =>
Boolean(
!controls?.secondary_metric?.value ||
controls?.secondary_metric?.value === controls?.metric.value,
),
},
linear_color_scheme: {
description: t(
'When a secondary metric is provided, a linear color scale is used.',
),
visibility: ({ controls }: ControlPanelsContainerProps) =>
Boolean(
controls?.secondary_metric?.value &&
controls?.secondary_metric?.value !== controls?.metric.value,
),
},
groupby: {
label: t('Hierarchy'),
description: t('This defines the level of the hierarchy'),
},
},
formDataOverrides: formData => ({
...formData,
groupby: getStandardizedControls().popAllColumns(),
metric: getStandardizedControls().shiftMetric(),
secondary_metric: getStandardizedControls().shiftMetric(),
}),
};
export default config;

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

View File

@@ -0,0 +1,51 @@
/**
* 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, Behavior } from '@superset-ui/core';
import transformProps from './transformProps';
import thumbnail from './images/thumbnail.png';
import controlPanel from './controlPanel';
import buildQuery from './buildQuery';
export default class EchartsSunburstChartPlugin extends ChartPlugin {
constructor() {
super({
buildQuery,
controlPanel,
loadChart: () => import('./EchartsSunburst'),
metadata: new ChartMetadata({
behaviors: [Behavior.INTERACTIVE_CHART],
category: t('Part of a Whole'),
credits: ['https://echarts.apache.org'],
description: t(
'Uses circles to visualize the flow of data through different stages of a system. Hover over individual paths in the visualization to understand the stages a value took. Useful for multi-stage, multi-group visualizing funnels and pipelines.',
),
exampleGallery: [],
name: t('Sunburst Chart v2'),
tags: [
t('ECharts'),
t('Aesthetic'),
t('Multi-Levels'),
t('Proportional'),
],
thumbnail,
}),
transformProps,
});
}
}

View File

@@ -0,0 +1,362 @@
/**
* 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 {
CategoricalColorNamespace,
getColumnLabel,
getMetricLabel,
getNumberFormatter,
getSequentialSchemeRegistry,
getTimeFormatter,
NumberFormats,
NumberFormatter,
t,
} from '@superset-ui/core';
import { EChartsCoreOption } from 'echarts';
import { SunburstSeriesNodeItemOption } from 'echarts/types/src/chart/sunburst/SunburstSeries';
import { CallbackDataParams } from 'echarts/types/src/util/types';
import { OpacityEnum } from '../constants';
import { defaultGrid, defaultTooltip } from '../defaults';
import { Refs } from '../types';
import { formatSeriesName, getColtypesMapping } from '../utils/series';
import { treeBuilder, TreeNode } from '../utils/treeBuilder';
import {
EchartsSunburstChartProps,
EchartsSunburstLabelType,
SunburstTransformedProps,
} from './types';
export function getLinearDomain(
treeData: TreeNode[],
callback: (treeNode: TreeNode) => number,
) {
let min = 0;
let max = 0;
let temp = null;
function traverse(tree: TreeNode[]) {
tree.forEach(treeNode => {
if (treeNode.children?.length) {
traverse(treeNode.children);
}
temp = callback(treeNode);
if (temp !== null) {
if (min > temp) min = temp;
if (max < temp) max = temp;
}
});
}
traverse(treeData);
return [min, max];
}
export function formatLabel({
params,
labelType,
numberFormatter,
}: {
params: CallbackDataParams;
labelType: EchartsSunburstLabelType;
numberFormatter: NumberFormatter;
}): string {
const { name = '', value } = params;
const formattedValue = numberFormatter(value as number);
switch (labelType) {
case EchartsSunburstLabelType.Key:
return name;
case EchartsSunburstLabelType.Value:
return formattedValue;
case EchartsSunburstLabelType.KeyValue:
return `${name}: ${formattedValue}`;
default:
return name;
}
}
export function formatTooltip({
params,
numberFormatter,
colorByCategory,
totalValue,
metricLabel,
secondaryMetricLabel,
}: {
params: CallbackDataParams & {
treePathInfo: {
name: string;
dataIndex: number;
value: number;
}[];
};
numberFormatter: NumberFormatter;
colorByCategory: boolean;
totalValue: number;
metricLabel: string;
secondaryMetricLabel?: string;
}): string {
const { data, treePathInfo = [] } = params;
treePathInfo.shift();
const node = data as TreeNode;
const formattedValue = numberFormatter(node.value);
const formattedSecondaryValue = numberFormatter(node.secondaryValue);
const percentFormatter = getNumberFormatter(NumberFormats.PERCENT_2_POINT);
const compareValuePercentage = percentFormatter(
node.secondaryValue / node.value,
);
const absolutePercentage = percentFormatter(node.value / totalValue);
const parentNode = treePathInfo[treePathInfo.length - 1];
const result = [
`<div style="font-size: 14px;font-weight: 600">${absolutePercentage} of total</div>`,
];
if (parentNode) {
const conditionalPercentage = percentFormatter(
node.value / parentNode.value,
);
result.push(`
<div style="font-size: 12px;">
${conditionalPercentage} of parent
</div>`);
}
result.push(
`<div style="color: '#666666'">
${metricLabel}: ${formattedValue}${
colorByCategory
? ''
: `, ${secondaryMetricLabel}: ${formattedSecondaryValue}`
}
</div>`,
colorByCategory
? ''
: `<div style="color: '#666666'">
${metricLabel}/${secondaryMetricLabel}: ${compareValuePercentage}
</div>`,
);
return result.join('\n');
}
export default function transformProps(
chartProps: EchartsSunburstChartProps,
): SunburstTransformedProps {
const {
formData,
height,
hooks,
filterState,
queriesData,
width,
theme,
inContextMenu,
} = chartProps;
const { data = [] } = queriesData[0];
const coltypeMapping = getColtypesMapping(queriesData[0]);
const {
groupby = [],
columns = [],
metric = '',
secondaryMetric = '',
colorScheme,
linearColorScheme,
labelType,
numberFormat,
dateFormat,
showLabels,
showLabelsThreshold,
showTotal,
sliceId,
emitFilter,
} = formData;
const refs: Refs = {};
const numberFormatter = getNumberFormatter(numberFormat);
const formatter = (params: CallbackDataParams) =>
formatLabel({
params,
numberFormatter,
labelType,
});
const minShowLabelAngle = (showLabelsThreshold || 0) * 3.6;
const padding = {
top: theme.gridUnit * 3,
right: theme.gridUnit,
bottom: theme.gridUnit * 3,
left: theme.gridUnit,
};
const containerWidth = width;
const containerHeight = height;
const visWidth = containerWidth - padding.left - padding.right;
const visHeight = containerHeight - padding.top - padding.bottom;
const radius = Math.min(visWidth, visHeight) / 2;
const { setDataMask = () => {}, onContextMenu } = hooks;
const columnsLabelMap = new Map<string, string[]>();
const metricLabel = getMetricLabel(metric);
const secondaryMetricLabel = secondaryMetric
? getMetricLabel(secondaryMetric)
: undefined;
const columnLabels = columns.map(getColumnLabel);
const treeData = treeBuilder(
data,
columnLabels,
metricLabel,
secondaryMetricLabel,
);
const totalValue = treeData.reduce(
(result, treeNode) => result + treeNode.value,
0,
);
const totalSecondaryValue = treeData.reduce(
(result, treeNode) => result + treeNode.secondaryValue,
0,
);
const categoricalColorScale = CategoricalColorNamespace.getScale(
colorScheme as string,
);
let linearColorScale: any;
let colorByCategory = true;
if (secondaryMetric && metric !== secondaryMetric) {
const domain = getLinearDomain(
treeData,
node => node.secondaryValue / node.value,
);
colorByCategory = false;
linearColorScale = getSequentialSchemeRegistry()
?.get(linearColorScheme)
?.createLinearScale(domain);
}
// add a base color to keep feature parity
if (colorByCategory) {
categoricalColorScale(metricLabel, sliceId);
} else {
linearColorScale(totalSecondaryValue / totalValue);
}
const traverse = (treeNodes: TreeNode[], path: string[]) =>
treeNodes.map(treeNode => {
const { name: nodeName, value, secondaryValue, groupBy } = treeNode;
let name = formatSeriesName(nodeName, {
numberFormatter,
timeFormatter: getTimeFormatter(dateFormat),
...(coltypeMapping[groupBy] && {
coltype: coltypeMapping[groupBy],
}),
});
const newPath = path.concat(name);
let item: SunburstSeriesNodeItemOption = {
name,
value,
// @ts-ignore
secondaryValue,
itemStyle: {
color: colorByCategory
? categoricalColorScale(name, sliceId)
: linearColorScale(secondaryValue / value),
},
};
if (treeNode.children?.length) {
item.children = traverse(treeNode.children, newPath);
} else {
name = newPath.join(',');
}
columnsLabelMap.set(name, newPath);
if (filterState.selectedValues?.[0]?.includes(name) === false) {
item = {
...item,
itemStyle: {
...item.itemStyle,
opacity: OpacityEnum.SemiTransparent,
},
label: {
color: `rgba(0, 0, 0, ${OpacityEnum.SemiTransparent})`,
},
};
}
return item;
});
const echartOptions: EChartsCoreOption = {
grid: {
...defaultGrid,
},
tooltip: {
...defaultTooltip,
show: !inContextMenu,
trigger: 'item',
formatter: (params: any) =>
formatTooltip({
params,
numberFormatter,
colorByCategory,
totalValue,
metricLabel,
secondaryMetricLabel,
}),
},
series: [
{
type: 'sunburst',
...padding,
nodeClick: false,
emphasis: {
focus: 'ancestor',
label: {
show: showLabels,
},
},
label: {
width: (radius * 0.6) / (columns.length || 1),
show: showLabels,
formatter,
color: theme.colors.grayscale.dark2,
minAngle: minShowLabelAngle,
overflow: 'breakAll',
},
radius: [radius * 0.3, radius],
data: traverse(treeData, []),
},
],
graphic: showTotal
? {
type: 'text',
top: 'center',
left: 'center',
style: {
text: t('Total: %s', numberFormatter(totalValue)),
fontSize: 16,
fontWeight: 'bold',
},
z: 10,
}
: null,
};
return {
formData,
width,
height,
echartOptions,
setDataMask,
emitFilter,
labelMap: Object.fromEntries(columnsLabelMap),
groupby,
selectedValues: filterState.selectedValues || [],
onContextMenu,
refs,
};
}

View File

@@ -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
* 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 {
ChartDataResponseResult,
ChartProps,
QueryFormColumn,
QueryFormData,
QueryFormMetric,
} from '@superset-ui/core';
import {
BaseTransformedProps,
ContextMenuTransformedProps,
CrossFilterTransformedProps,
} from '../types';
export type EchartsSunburstFormData = QueryFormData & {
groupby: QueryFormColumn[];
metric: QueryFormMetric;
secondaryMetric?: QueryFormMetric;
colorScheme?: string;
linearColorScheme?: string;
emitFilter: boolean;
};
export enum EchartsSunburstLabelType {
Key = 'key',
Value = 'value',
KeyValue = 'key_value',
}
export const DEFAULT_FORM_DATA: Partial<EchartsSunburstFormData> = {
groupby: [],
numberFormat: 'SMART_NUMBER',
labelType: EchartsSunburstLabelType.Key,
showLabels: false,
dateFormat: 'smart_date',
emitFilter: false,
};
export interface EchartsSunburstChartProps
extends ChartProps<EchartsSunburstFormData> {
formData: EchartsSunburstFormData;
queriesData: ChartDataResponseResult[];
}
export type SunburstTransformedProps =
BaseTransformedProps<EchartsSunburstFormData> &
ContextMenuTransformedProps &
CrossFilterTransformedProps;

View File

@@ -17,7 +17,7 @@
* under the License.
*/
import { TreePathInfo } from './types';
import { TreePathInfo } from '../types';
export const COLOR_SATURATION = [0.7, 0.4];
export const LABEL_FONTSIZE = 11;

View File

@@ -18,7 +18,6 @@
*/
import {
CategoricalColorNamespace,
DataRecord,
getColumnLabel,
getMetricLabel,
getNumberFormatter,
@@ -26,7 +25,6 @@ import {
NumberFormats,
NumberFormatter,
} from '@superset-ui/core';
import { groupBy, isNumber, transform } from 'lodash';
import { TreemapSeriesNodeItemOption } from 'echarts/types/src/chart/treemap/TreemapSeries';
import { EChartsCoreOption, TreemapSeriesOption } from 'echarts';
import {
@@ -49,6 +47,7 @@ import {
import { OpacityEnum } from '../constants';
import { getDefaultTooltip } from '../utils/tooltip';
import { Refs } from '../types';
import { treeBuilder, TreeNode } from '../utils/treeBuilder';
export function formatLabel({
params,
@@ -151,97 +150,58 @@ export default function transformProps(
});
const columnsLabelMap = new Map<string, string[]>();
const transformer = (
data: DataRecord[],
groupbyLabels: string[],
metric: string,
depth: number,
path: string[],
): TreemapSeriesNodeItemOption[] => {
const [currGroupby, ...restGroupby] = groupbyLabels;
const currGrouping = groupBy(data, currGroupby);
if (!restGroupby.length) {
return transform(
currGrouping,
(result, value, key) => {
(value ?? []).forEach(datum => {
const name = formatSeriesName(key, {
numberFormatter,
timeFormatter: getTimeFormatter(dateFormat),
...(coltypeMapping[currGroupby] && {
coltype: coltypeMapping[currGroupby],
}),
});
const item: TreemapSeriesNodeItemOption = {
name,
value: isNumber(datum[metric]) ? (datum[metric] as number) : 0,
};
const joinedName = path.concat(name).join(',');
// map(joined_name: [columnLabel_1, columnLabel_2, ...])
columnsLabelMap.set(joinedName, path.concat(name));
if (
filterState.selectedValues &&
!filterState.selectedValues.includes(joinedName)
) {
item.itemStyle = {
colorAlpha: OpacityEnum.SemiTransparent,
};
item.label = {
color: `rgba(0, 0, 0, ${OpacityEnum.SemiTransparent})`,
};
}
result.push(item);
});
},
[] as TreemapSeriesNodeItemOption[],
);
}
const sortedData = transform(
currGrouping,
(result, value, key) => {
const name = formatSeriesName(key, {
numberFormatter,
timeFormatter: getTimeFormatter(dateFormat),
...(coltypeMapping[currGroupby] && {
coltype: coltypeMapping[currGroupby],
}),
});
const children = transformer(
value,
restGroupby,
metric,
depth + 1,
path.concat(name),
);
result.push({
name,
children,
value: children.reduce(
(prev, cur) => prev + (cur.value as number),
0,
),
});
result.sort((a, b) => (b.value as number) - (a.value as number));
},
[] as TreemapSeriesNodeItemOption[],
);
// sort according to the area and then take the color value in order
return sortedData.map(child => ({
...child,
colorSaturation: COLOR_SATURATION,
itemStyle: {
borderColor: BORDER_COLOR,
color: colorFn(`${child.name}`, sliceId),
borderWidth: BORDER_WIDTH,
gapWidth: GAP_WIDTH,
},
}));
};
const metricLabel = getMetricLabel(metric);
const groupbyLabels = groupby.map(getColumnLabel);
const initialDepth = 1;
const treeData = treeBuilder(data, groupbyLabels, metricLabel);
const traverse = (treeNodes: TreeNode[], path: string[]) =>
treeNodes.map(treeNode => {
const { name: nodeName, value, groupBy } = treeNode;
const name = formatSeriesName(nodeName, {
numberFormatter,
timeFormatter: getTimeFormatter(dateFormat),
...(coltypeMapping[groupBy] && {
coltype: coltypeMapping[groupBy],
}),
});
const newPath = path.concat(name);
let item: TreemapSeriesNodeItemOption = {
name,
value,
};
if (treeNode.children?.length) {
item = {
...item,
children: traverse(treeNode.children, newPath),
colorSaturation: COLOR_SATURATION,
itemStyle: {
borderColor: BORDER_COLOR,
color: colorFn(name, sliceId),
borderWidth: BORDER_WIDTH,
gapWidth: GAP_WIDTH,
},
};
} else {
const joinedName = newPath.join(',');
// map(joined_name: [columnLabel_1, columnLabel_2, ...])
columnsLabelMap.set(joinedName, newPath);
if (
filterState.selectedValues &&
!filterState.selectedValues.includes(joinedName)
) {
item = {
...item,
itemStyle: {
colorAlpha: OpacityEnum.SemiTransparent,
},
label: {
color: `rgba(0, 0, 0, ${OpacityEnum.SemiTransparent})`,
},
};
}
}
return item;
});
const transformedData: TreemapSeriesNodeItemOption[] = [
{
name: metricLabel,
@@ -255,7 +215,7 @@ export default function transformProps(
upperLabel: {
show: false,
},
children: transformer(data, groupbyLabels, metricLabel, initialDepth, []),
children: traverse(treeData, []),
},
];

View File

@@ -29,6 +29,7 @@ import {
ContextMenuTransformedProps,
CrossFilterTransformedProps,
LabelPositionEnum,
TreePathInfo,
} from '../types';
export type EchartsTreemapFormData = QueryFormData & {
@@ -67,12 +68,6 @@ export const DEFAULT_FORM_DATA: Partial<EchartsTreemapFormData> = {
dateFormat: 'smart_date',
emitFilter: false,
};
export interface TreePathInfo {
name: string;
dataIndex: number;
value: number | number[];
}
export interface TreemapSeriesCallbackDataParams extends CallbackDataParams {
treePathInfo?: TreePathInfo[];
}

View File

@@ -33,6 +33,7 @@ export { default as EchartsFunnelChartPlugin } from './Funnel';
export { default as EchartsTreeChartPlugin } from './Tree';
export { default as EchartsTreemapChartPlugin } from './Treemap';
export { BigNumberChartPlugin, BigNumberTotalChartPlugin } from './BigNumber';
export { default as EchartsSunburstChartPlugin } from './Sunburst';
export { default as BoxPlotTransformProps } from './BoxPlot/transformProps';
export { default as FunnelTransformProps } from './Funnel/transformProps';
@@ -44,6 +45,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 SunburstTransformProps } from './Sunburst/transformProps';
export { DEFAULT_FORM_DATA as TimeseriesDefaultFormData } from './Timeseries/constants';

View File

@@ -22,6 +22,7 @@ import {
ChartDataResponseResult,
ChartProps,
HandlerFunction,
PlainObject,
QueryFormColumn,
SetDataMaskHook,
} from '@superset-ui/core';
@@ -111,7 +112,7 @@ export enum LabelPositionEnum {
InsideBottomRight = 'insideBottomRight',
}
export interface BaseChartProps<T> extends ChartProps<T> {
export interface BaseChartProps<T extends PlainObject> extends ChartProps<T> {
queriesData: ChartDataResponseResult[];
}
@@ -155,4 +156,10 @@ export interface TitleFormData {
export type StackType = boolean | null | Partial<AreaChartExtraControlsValue>;
export interface TreePathInfo {
name: string;
dataIndex: number;
value: number | number[];
}
export * from './Timeseries/types';

View File

@@ -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.
*/
import { DataRecord } from '@superset-ui/core';
import _ from 'lodash';
export type TreeNode = {
name: string;
value: number;
secondaryValue: number;
groupBy: string;
children?: TreeNode[];
};
export function treeBuilder(
data: DataRecord[],
groupBy: string[],
metric: string,
secondaryMetric?: string,
): TreeNode[] {
const [curGroupBy, ...restGroupby] = groupBy;
const curData = _.groupBy(data, curGroupBy);
return _.transform(
curData,
(result, value, name) => {
if (!restGroupby.length) {
(value ?? []).forEach(datum => {
const metricValue = getMetricValue(datum, metric);
const secondaryValue = secondaryMetric
? getMetricValue(datum, secondaryMetric)
: metricValue;
const item = {
name,
value: metricValue,
secondaryValue,
groupBy: curGroupBy,
};
result.push(item);
});
} else {
const children = treeBuilder(
value,
restGroupby,
metric,
secondaryMetric,
);
const metricValue = children.reduce(
(prev, cur) => prev + (cur.value as number),
0,
);
const secondaryValue = secondaryMetric
? children.reduce(
(prev, cur) => prev + (cur.secondaryValue as number),
0,
)
: metricValue;
result.push({
name,
children,
value: metricValue,
secondaryValue,
groupBy: curGroupBy,
});
}
},
[] as TreeNode[],
);
}
function getMetricValue(datum: DataRecord, metric: string) {
return _.isNumber(datum[metric]) ? (datum[metric] as number) : 0;
}

View File

@@ -0,0 +1,274 @@
/**
* 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 { treeBuilder } from '../../src/utils/treeBuilder';
describe('test treeBuilder', () => {
const data = [
{
foo: 'a-1',
bar: 'a',
count: 2,
count2: 3,
},
{
foo: 'a-2',
bar: 'a',
count: 2,
count2: 3,
},
{
foo: 'b-1',
bar: 'b',
count: 2,
count2: 3,
},
{
foo: 'b-2',
bar: 'b',
count: 2,
count2: 3,
},
{
foo: 'c-1',
bar: 'c',
count: 2,
count2: 3,
},
{
foo: 'c-2',
bar: 'c',
count: 2,
count2: 3,
},
{
foo: 'd-1',
bar: 'd',
count: 2,
count2: 3,
},
];
it('should build tree as expected', () => {
const tree = treeBuilder(data, ['foo', 'bar'], 'count');
expect(tree).toEqual([
{
children: [
{
groupBy: 'bar',
name: 'a',
secondaryValue: 2,
value: 2,
},
],
groupBy: 'foo',
name: 'a-1',
secondaryValue: 2,
value: 2,
},
{
children: [
{
groupBy: 'bar',
name: 'a',
secondaryValue: 2,
value: 2,
},
],
groupBy: 'foo',
name: 'a-2',
secondaryValue: 2,
value: 2,
},
{
children: [
{
groupBy: 'bar',
name: 'b',
secondaryValue: 2,
value: 2,
},
],
groupBy: 'foo',
name: 'b-1',
secondaryValue: 2,
value: 2,
},
{
children: [
{
groupBy: 'bar',
name: 'b',
secondaryValue: 2,
value: 2,
},
],
groupBy: 'foo',
name: 'b-2',
secondaryValue: 2,
value: 2,
},
{
children: [
{
groupBy: 'bar',
name: 'c',
secondaryValue: 2,
value: 2,
},
],
groupBy: 'foo',
name: 'c-1',
secondaryValue: 2,
value: 2,
},
{
children: [
{
groupBy: 'bar',
name: 'c',
secondaryValue: 2,
value: 2,
},
],
groupBy: 'foo',
name: 'c-2',
secondaryValue: 2,
value: 2,
},
{
children: [
{
groupBy: 'bar',
name: 'd',
secondaryValue: 2,
value: 2,
},
],
groupBy: 'foo',
name: 'd-1',
secondaryValue: 2,
value: 2,
},
]);
});
it('should build tree with secondaryValue as expected', () => {
const tree = treeBuilder(data, ['foo', 'bar'], 'count', 'count2');
expect(tree).toEqual([
{
children: [
{
groupBy: 'bar',
name: 'a',
secondaryValue: 3,
value: 2,
},
],
groupBy: 'foo',
name: 'a-1',
secondaryValue: 3,
value: 2,
},
{
children: [
{
groupBy: 'bar',
name: 'a',
secondaryValue: 3,
value: 2,
},
],
groupBy: 'foo',
name: 'a-2',
secondaryValue: 3,
value: 2,
},
{
children: [
{
groupBy: 'bar',
name: 'b',
secondaryValue: 3,
value: 2,
},
],
groupBy: 'foo',
name: 'b-1',
secondaryValue: 3,
value: 2,
},
{
children: [
{
groupBy: 'bar',
name: 'b',
secondaryValue: 3,
value: 2,
},
],
groupBy: 'foo',
name: 'b-2',
secondaryValue: 3,
value: 2,
},
{
children: [
{
groupBy: 'bar',
name: 'c',
secondaryValue: 3,
value: 2,
},
],
groupBy: 'foo',
name: 'c-1',
secondaryValue: 3,
value: 2,
},
{
children: [
{
groupBy: 'bar',
name: 'c',
secondaryValue: 3,
value: 2,
},
],
groupBy: 'foo',
name: 'c-2',
secondaryValue: 3,
value: 2,
},
{
children: [
{
groupBy: 'bar',
name: 'd',
secondaryValue: 3,
value: 2,
},
],
groupBy: 'foo',
name: 'd-1',
secondaryValue: 3,
value: 2,
},
]);
});
});