diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Sunburst/EchartsSunburst.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Sunburst/EchartsSunburst.tsx new file mode 100644 index 0000000000000..08f381b15d323 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Sunburst/EchartsSunburst.tsx @@ -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 ( + + ); +} diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Sunburst/buildQuery.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Sunburst/buildQuery.ts new file mode 100644 index 0000000000000..8b47fb5e725cc --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Sunburst/buildQuery.ts @@ -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]] }), + }, + ]); +} diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Sunburst/controlPanel.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Sunburst/controlPanel.tsx new file mode 100644 index 0000000000000..9c4c5ae5bc46c --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Sunburst/controlPanel.tsx @@ -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'], + [
{t('Labels')}
], + [ + { + 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; diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Sunburst/images/thumbnail.png b/superset-frontend/plugins/plugin-chart-echarts/src/Sunburst/images/thumbnail.png new file mode 100644 index 0000000000000..7afef30bd4e6e Binary files /dev/null and b/superset-frontend/plugins/plugin-chart-echarts/src/Sunburst/images/thumbnail.png differ diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Sunburst/index.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Sunburst/index.ts new file mode 100644 index 0000000000000..fe75a7916fefd --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Sunburst/index.ts @@ -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, + }); + } +} diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Sunburst/transformProps.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Sunburst/transformProps.ts new file mode 100644 index 0000000000000..4677e1af694c8 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Sunburst/transformProps.ts @@ -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 = [ + `
${absolutePercentage} of total
`, + ]; + if (parentNode) { + const conditionalPercentage = percentFormatter( + node.value / parentNode.value, + ); + result.push(` +
+ ${conditionalPercentage} of parent +
`); + } + result.push( + `
+ ${metricLabel}: ${formattedValue}${ + colorByCategory + ? '' + : `, ${secondaryMetricLabel}: ${formattedSecondaryValue}` + } +
`, + colorByCategory + ? '' + : `
+ ${metricLabel}/${secondaryMetricLabel}: ${compareValuePercentage} +
`, + ); + 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(); + 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, + }; +} diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Sunburst/types.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Sunburst/types.ts new file mode 100644 index 0000000000000..fdd834c94e6d6 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Sunburst/types.ts @@ -0,0 +1,66 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * 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 = { + groupby: [], + numberFormat: 'SMART_NUMBER', + labelType: EchartsSunburstLabelType.Key, + showLabels: false, + dateFormat: 'smart_date', + emitFilter: false, +}; + +export interface EchartsSunburstChartProps + extends ChartProps { + formData: EchartsSunburstFormData; + queriesData: ChartDataResponseResult[]; +} + +export type SunburstTransformedProps = + BaseTransformedProps & + ContextMenuTransformedProps & + CrossFilterTransformedProps; diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Treemap/constants.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Treemap/constants.ts index 080565229ac26..2cc660f35f5a6 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Treemap/constants.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Treemap/constants.ts @@ -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; diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Treemap/transformProps.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Treemap/transformProps.ts index 5c4b4cd936176..eb1b64ef30065 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Treemap/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Treemap/transformProps.ts @@ -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(); - - 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, []), }, ]; diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Treemap/types.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Treemap/types.ts index c318b2ac2a366..8d41b9d2e40bf 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Treemap/types.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Treemap/types.ts @@ -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 = { dateFormat: 'smart_date', emitFilter: false, }; - -export interface TreePathInfo { - name: string; - dataIndex: number; - value: number | number[]; -} export interface TreemapSeriesCallbackDataParams extends CallbackDataParams { treePathInfo?: TreePathInfo[]; } diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/index.ts b/superset-frontend/plugins/plugin-chart-echarts/src/index.ts index 9890eb4c13e89..0301f265b051a 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/index.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/index.ts @@ -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'; diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/types.ts b/superset-frontend/plugins/plugin-chart-echarts/src/types.ts index 57ed645839992..5bd56eb8ed0d4 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/types.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/types.ts @@ -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 extends ChartProps { +export interface BaseChartProps extends ChartProps { queriesData: ChartDataResponseResult[]; } @@ -155,4 +156,10 @@ export interface TitleFormData { export type StackType = boolean | null | Partial; +export interface TreePathInfo { + name: string; + dataIndex: number; + value: number | number[]; +} + export * from './Timeseries/types'; diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/utils/treeBuilder.ts b/superset-frontend/plugins/plugin-chart-echarts/src/utils/treeBuilder.ts new file mode 100644 index 0000000000000..32e0416a6b849 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-echarts/src/utils/treeBuilder.ts @@ -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; +} diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/utils/treeBuilder.test.ts b/superset-frontend/plugins/plugin-chart-echarts/test/utils/treeBuilder.test.ts new file mode 100644 index 0000000000000..b91349a895358 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-echarts/test/utils/treeBuilder.test.ts @@ -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, + }, + ]); + }); +});