/** * 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, ensureIsInt, getColumnLabel, getMetricLabel, getNumberFormatter, getTimeFormatter, NumberFormatter, isDefined, } from '@superset-ui/core'; import type { CallbackDataParams } from 'echarts/types/src/util/types'; import type { RadarSeriesDataItemOption } from 'echarts/types/src/chart/radar/RadarSeries'; import type { EChartsCoreOption } from 'echarts/core'; import type { RadarSeriesOption } from 'echarts/charts'; import { DEFAULT_FORM_DATA as DEFAULT_RADAR_FORM_DATA, EchartsRadarChartProps, EchartsRadarFormData, EchartsRadarLabelType, RadarChartTransformedProps, SeriesNormalizedMap, } from './types'; import { DEFAULT_LEGEND_FORM_DATA, OpacityEnum } from '../constants'; import { extractGroupbyLabel, getChartPadding, getColtypesMapping, getLegendProps, } from '../utils/series'; import { resolveLegendLayout } from '../utils/legendLayout'; import { defaultGrid } from '../defaults'; import { Refs } from '../types'; import { getDefaultTooltip } from '../utils/tooltip'; import { findGlobalMax, renderNormalizedTooltip } from './utils'; export function formatLabel({ params, labelType, numberFormatter, getDenormalizedSeriesValue, metricsWithCustomBounds, metricLabels, }: { params: CallbackDataParams; labelType: EchartsRadarLabelType; numberFormatter: NumberFormatter; getDenormalizedSeriesValue: (seriesName: string, value: string) => number; metricsWithCustomBounds: Set; metricLabels: string[]; }): string { const { name = '', value, dimensionIndex = 0 } = params; const metricLabel = metricLabels[dimensionIndex]; const formattedValue = numberFormatter( metricsWithCustomBounds.has(metricLabel) ? (value as number) : (getDenormalizedSeriesValue(name, String(value)) as number), ); switch (labelType) { case EchartsRadarLabelType.Value: return formattedValue; case EchartsRadarLabelType.KeyValue: return `${name}: ${formattedValue}`; default: return name; } } export default function transformProps( chartProps: EchartsRadarChartProps, ): RadarChartTransformedProps { const { formData, height, hooks, filterState, queriesData, width, theme, inContextMenu, emitCrossFilters, } = chartProps; const refs: Refs = {}; const { data = [] } = queriesData[0]; const globalMax = findGlobalMax(data, Object.keys(data[0] || {})); const coltypeMapping = getColtypesMapping(queriesData[0]); const { colorScheme, groupby, labelType, labelPosition, legendOrientation, legendType, legendMargin, metrics = [], numberFormat, dateFormat, showLabels, showLegend, legendSort, isCircle, columnConfig, sliceId, }: EchartsRadarFormData = { ...DEFAULT_LEGEND_FORM_DATA, ...DEFAULT_RADAR_FORM_DATA, ...formData, }; const { setDataMask = () => {}, onContextMenu } = hooks ?? {}; const colorFn = CategoricalColorNamespace.getScale(colorScheme as string); const numberFormatter = getNumberFormatter(numberFormat); const denormalizedSeriesValues: SeriesNormalizedMap = {}; const getDenormalizedSeriesValue = ( seriesName: string, normalizedValue: string, ): number => denormalizedSeriesValues?.[seriesName]?.[normalizedValue] ?? Number(normalizedValue); const metricLabels = metrics.map(getMetricLabel); const metricsWithCustomBounds = new Set( metricLabels.filter(metricLabel => { const config = columnConfig?.[metricLabel]; const hasMax = !!isDefined(config?.radarMetricMaxValue); const hasMin = isDefined(config?.radarMetricMinValue) && config?.radarMetricMinValue !== 0; return hasMax || hasMin; }), ); const formatter = (params: CallbackDataParams) => formatLabel({ params, numberFormatter, labelType, getDenormalizedSeriesValue, metricsWithCustomBounds, metricLabels, }); const groupbyLabels = groupby.map(getColumnLabel); const metricLabelAndMaxValueMap = new Map(); const metricLabelAndMinValueMap = new Map(); const columnsLabelMap = new Map(); const transformedData: RadarSeriesDataItemOption[] = []; data.forEach(datum => { const joinedName = extractGroupbyLabel({ datum, groupby: groupbyLabels, coltypeMapping, timeFormatter: getTimeFormatter(dateFormat), }); // map(joined_name: [columnLabel_1, columnLabel_2, ...]) columnsLabelMap.set( joinedName, groupbyLabels.map(col => datum[col] as string), ); // put max value of series into metricLabelAndMaxValueMap // eslint-disable-next-line no-restricted-syntax for (const [metricLabel, value] of Object.entries(datum)) { if (metricLabelAndMaxValueMap.has(metricLabel)) { metricLabelAndMaxValueMap.set( metricLabel, Math.max( value as number, ensureIsInt( metricLabelAndMaxValueMap.get(metricLabel), Number.MIN_SAFE_INTEGER, ), ), ); } else { metricLabelAndMaxValueMap.set(metricLabel, value as number); } if (metricLabelAndMinValueMap.has(metricLabel)) { metricLabelAndMinValueMap.set( metricLabel, Math.min( value as number, ensureIsInt( metricLabelAndMinValueMap.get(metricLabel), Number.MAX_SAFE_INTEGER, ), ), ); } else { metricLabelAndMinValueMap.set(metricLabel, value as number); } } const isFiltered = filterState.selectedValues && !filterState.selectedValues.includes(joinedName); // generate transformedData transformedData.push({ value: metricLabels.map(metricLabel => datum[metricLabel]), name: joinedName, itemStyle: { color: colorFn(joinedName, sliceId), opacity: isFiltered ? OpacityEnum.Transparent : OpacityEnum.NonTransparent, }, lineStyle: { opacity: isFiltered ? OpacityEnum.SemiTransparent : OpacityEnum.NonTransparent, }, label: { show: showLabels, position: labelPosition, formatter, }, } as RadarSeriesDataItemOption); }); const selectedValues = (filterState.selectedValues || []).reduce( (acc: Record, selectedValue: string) => { const index = transformedData.findIndex( ({ name }) => name === selectedValue, ); return { ...acc, [index]: selectedValue, }; }, {}, ); const normalizeArray = (arr: number[], decimals = 10, seriesName: string) => arr.map((value, index) => { const metricLabel = metricLabels[index]; if (metricsWithCustomBounds.has(metricLabel)) { return value; } const max = Math.max(...arr); const normalizedValue = Number((value / max).toFixed(decimals)); denormalizedSeriesValues[seriesName][String(normalizedValue)] = value; return normalizedValue; }); // Normalize the transformed data const normalizedTransformedData = transformedData.map(series => { if (Array.isArray(series.value)) { const seriesName = String(series?.name || ''); denormalizedSeriesValues[seriesName] = {}; return { ...series, value: normalizeArray(series.value as number[], 10, seriesName), }; } return series; }); const indicator = metricLabels.map(metricLabel => { const isMetricWithCustomBounds = metricsWithCustomBounds.has(metricLabel); if (!isMetricWithCustomBounds) { return { name: metricLabel, max: 1, min: 0, }; } const maxValueInControl = columnConfig?.[metricLabel]?.radarMetricMaxValue; const minValueInControl = columnConfig?.[metricLabel]?.radarMetricMinValue; // Ensure that 0 is at the center of the polar coordinates const maxValue = metricLabelAndMaxValueMap.get(metricLabel) === 0 ? Number.MAX_SAFE_INTEGER : globalMax; const max = isDefined(maxValueInControl) ? maxValueInControl : maxValue; let min: number; if (isDefined(minValueInControl)) { min = minValueInControl; } else { min = 0; } return { name: metricLabel, max, min, }; }); const legendData = Array.from(columnsLabelMap.keys()).sort( (a: string, b: string) => { if (!legendSort) return 0; return legendSort === 'asc' ? a.localeCompare(b) : b.localeCompare(a); }, ); const { effectiveLegendMargin, effectiveLegendType } = resolveLegendLayout({ chartHeight: height, chartWidth: width, legendItems: legendData, legendMargin, orientation: legendOrientation, show: showLegend, theme, type: legendType, }); const chartPadding = getChartPadding( showLegend, legendOrientation, effectiveLegendMargin, ); const series: RadarSeriesOption[] = [ { type: 'radar', ...chartPadding, animation: false, emphasis: { label: { show: true, fontWeight: 'bold', }, }, data: normalizedTransformedData, }, ]; const NormalizedTooltipFormater = ( params: CallbackDataParams & { color: string; name: string; value: number[]; }, ) => renderNormalizedTooltip( params, metricLabels, getDenormalizedSeriesValue, metricsWithCustomBounds, numberFormatter, ); const centerX = width ? ((width + chartPadding.left - chartPadding.right) / 2 / width) * 100 : 50; const centerY = height ? ((height + chartPadding.top - chartPadding.bottom) / 2 / height) * 100 : 50; const radarCenter: [string, string] = [`${centerX}%`, `${centerY}%`]; const echartOptions: EChartsCoreOption = { grid: { ...defaultGrid, }, tooltip: { ...getDefaultTooltip(refs), show: !inContextMenu, trigger: 'item', formatter: NormalizedTooltipFormater, }, legend: { ...getLegendProps( effectiveLegendType, legendOrientation, showLegend, theme, ), data: legendData, }, series, radar: { shape: isCircle ? 'circle' : 'polygon', indicator, splitLine: { show: true, lineStyle: { color: theme.colorSplit, }, }, center: radarCenter, splitArea: { show: true, areaStyle: { color: [theme.colorBgLayout, theme.colorBgContainer], }, }, axisLine: { lineStyle: { color: theme.colorSplit, }, }, }, }; return { formData, width, height, echartOptions, emitCrossFilters, setDataMask, labelMap: Object.fromEntries(columnsLabelMap), groupby, selectedValues, onContextMenu, refs, coltypeMapping, }; }