/** * 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 } from '@apache-superset/core'; import { extractTimegrain, getNumberFormatter, NumberFormats, getMetricLabel, getXAxisLabel, Metric, getValueFormatter, tooltipHtml, } from '@superset-ui/core'; import { GenericDataType } from '@apache-superset/core/api/core'; import { EChartsCoreOption, graphic } from 'echarts/core'; import { aggregationChoices } from '@superset-ui/chart-controls'; import { TIMESERIES_CONSTANTS } from '../../constants'; import { getXAxisFormatter } from '../../utils/formatters'; import { BigNumberVizProps, BigNumberDatum, BigNumberWithTrendlineChartProps, TimeSeriesDatum, } from '../types'; import { getDateFormatter, parseMetricValue, getOriginalLabel } from '../utils'; import { getDefaultTooltip } from '../../utils/tooltip'; import { Refs } from '../../types'; const formatPercentChange = getNumberFormatter( NumberFormats.PERCENT_SIGNED_1_POINT, ); // Client-side aggregation function using shared aggregationChoices function computeClientSideAggregation( data: [number | null, number | null][], aggregation: string | undefined | null, ): number | null { if (!data.length) return null; // Find the aggregation method, handling case variations const methodKey = Object.keys(aggregationChoices).find( key => key.toLowerCase() === (aggregation || '').toLowerCase(), ); // Use the compute method from aggregationChoices, fallback to LAST_VALUE const selectedMethod = methodKey ? aggregationChoices[methodKey as keyof typeof aggregationChoices] : aggregationChoices.LAST_VALUE; // Extract values from tuple array and filter out nulls const values = data .map(([, value]) => value) .filter((v): v is number => v !== null); return selectedMethod.compute(values); } export default function transformProps( chartProps: BigNumberWithTrendlineChartProps, ): BigNumberVizProps { const { width, height, queriesData, formData, rawFormData, hooks, inContextMenu, theme, datasource: { currencyFormats = {}, columnFormats = {}, currencyCodeColumn, }, } = chartProps; const { colorPicker, compareLag: compareLag_, compareSuffix = '', timeFormat, metricNameFontSize, headerFontSize, metric = 'value', showTimestamp, showTrendLine, subtitle = '', subtitleFontSize, aggregation, startYAxisAtZero, subheader = '', subheaderFontSize, forceTimestampFormatting, yAxisFormat, currencyFormat, timeRangeFixed, showXAxis = false, showXAxisMinMaxLabels = false, showYAxis = false, showYAxisMinMaxLabels = false, } = formData; const granularity = extractTimegrain(rawFormData); const { data = [], colnames = [], coltypes = [], from_dttm: fromDatetime, to_dttm: toDatetime, detected_currency: detectedCurrency, } = queriesData[0]; const aggregatedQueryData = queriesData.length > 1 ? queriesData[1] : null; const hasAggregatedData = aggregatedQueryData?.data && aggregatedQueryData.data.length > 0 && aggregation !== 'LAST_VALUE'; const aggregatedData = hasAggregatedData ? aggregatedQueryData.data[0] : null; const refs: Refs = {}; const metricName = getMetricLabel(metric); const metrics = chartProps.datasource?.metrics || []; const originalLabel = getOriginalLabel(metric, metrics); const showMetricName = chartProps.rawFormData?.show_metric_name ?? false; const compareLag = Number(compareLag_) || 0; let formattedSubheader = subheader; const { r, g, b } = colorPicker; const mainColor = `rgb(${r}, ${g}, ${b})`; const xAxisLabel = getXAxisLabel(rawFormData) as string; let trendLineData: TimeSeriesDatum[] | undefined; let percentChange = 0; let bigNumber = data.length === 0 ? null : data[0][metricName]; let timestamp = data.length === 0 ? null : data[0][xAxisLabel]; let bigNumberFallback = null; let sortedData: [number | null, number | null][] = []; if (data.length > 0) { sortedData = (data as BigNumberDatum[]) .map( d => [d[xAxisLabel], parseMetricValue(d[metricName])] as [ number | null, number | null, ], ) // sort in time descending order .sort((a, b) => (a[0] !== null && b[0] !== null ? b[0] - a[0] : 0)); } if (sortedData.length > 0) { timestamp = sortedData[0][0]; // Raw aggregation uses server-side data, all others use client-side if (aggregation === 'raw' && hasAggregatedData && aggregatedData) { // Use server-side aggregation for raw if ( aggregatedData[metricName] !== null && aggregatedData[metricName] !== undefined ) { bigNumber = aggregatedData[metricName]; } else { const metricKeys = Object.keys(aggregatedData).filter( key => key !== xAxisLabel && aggregatedData[key] !== null && typeof aggregatedData[key] === 'number', ); bigNumber = metricKeys.length > 0 ? aggregatedData[metricKeys[0]] : null; } } else { // Use client-side aggregation for all other methods bigNumber = computeClientSideAggregation(sortedData, aggregation); } // Handle null bigNumber case if (bigNumber === null) { bigNumberFallback = sortedData.find(d => d[1] !== null); bigNumber = bigNumberFallback ? bigNumberFallback[1] : null; timestamp = bigNumberFallback ? bigNumberFallback[0] : null; } } if (compareLag > 0 && sortedData.length > 0) { const compareIndex = compareLag; if (compareIndex < sortedData.length) { const compareFromValue = sortedData[compareIndex][1]; const compareToValue = sortedData[0][1]; // compare values must both be non-nulls if (compareToValue !== null && compareFromValue !== null) { percentChange = compareFromValue ? (Number(compareToValue) - compareFromValue) / Math.abs(compareFromValue) : 0; formattedSubheader = `${formatPercentChange( percentChange, )} ${compareSuffix}`; } } } if (data.length > 0) { const reversedData = [...sortedData].reverse(); // @ts-ignore trendLineData = showTrendLine ? reversedData : undefined; } let className = ''; if (percentChange > 0) { className = 'positive'; } else if (percentChange < 0) { className = 'negative'; } const metricColtypeIndex = colnames.findIndex(name => name === metricName); const metricColtype = metricColtypeIndex > -1 ? coltypes[metricColtypeIndex] : null; let metricEntry: Metric | undefined; if (chartProps.datasource?.metrics) { metricEntry = chartProps.datasource.metrics.find( metricEntry => metricEntry.metric_name === metric, ); } const formatTime = getDateFormatter( timeFormat, granularity, metricEntry?.d3format, ); if (trendLineData && timeRangeFixed && fromDatetime) { const toDatetimeOrToday = toDatetime ?? Date.now(); if (!trendLineData[0][0] || trendLineData[0][0] > fromDatetime) { trendLineData.unshift([fromDatetime, null]); } if ( !trendLineData[trendLineData.length - 1][0] || trendLineData[trendLineData.length - 1][0]! < toDatetimeOrToday ) { trendLineData.push([toDatetimeOrToday, null]); } } const numberFormatter = getValueFormatter( metric, currencyFormats, columnFormats, metricEntry?.d3format || yAxisFormat, currencyFormat, undefined, data, currencyCodeColumn, detectedCurrency, ); const xAxisFormatter = getXAxisFormatter(timeFormat); const yAxisFormatter = metricColtype === GenericDataType.Temporal || metricColtype === GenericDataType.String || forceTimestampFormatting ? formatTime : numberFormatter; const echartOptions: EChartsCoreOption = trendLineData ? { series: [ { data: trendLineData, type: 'line', smooth: true, symbol: 'circle', symbolSize: 10, showSymbol: false, color: mainColor, areaStyle: { color: new graphic.LinearGradient(0, 0, 0, 1, [ { offset: 0, color: mainColor, }, { offset: 1, color: theme.colorBgContainer, }, ]), }, }, ], xAxis: { type: 'time', show: showXAxis, splitLine: { show: false, }, axisLabel: { hideOverlap: true, formatter: xAxisFormatter, alignMinLabel: 'left', alignMaxLabel: 'right', showMinLabel: showXAxisMinMaxLabels, showMaxLabel: showXAxisMinMaxLabels, }, }, yAxis: { type: 'value', show: showYAxis, scale: !startYAxisAtZero, splitLine: { show: false, }, axisLabel: { hideOverlap: true, formatter: yAxisFormatter, showMinLabel: showYAxisMinMaxLabels, showMaxLabel: showYAxisMinMaxLabels, }, }, grid: showXAxis || showYAxis ? { containLabel: true, bottom: TIMESERIES_CONSTANTS.gridOffsetBottom, left: TIMESERIES_CONSTANTS.gridOffsetLeft, right: TIMESERIES_CONSTANTS.gridOffsetRight, top: TIMESERIES_CONSTANTS.gridOffsetTop, } : { bottom: 0, left: 0, right: 0, top: 0, }, tooltip: { ...getDefaultTooltip(refs), show: !inContextMenu, trigger: 'axis', formatter: (params: { data: TimeSeriesDatum }[]) => tooltipHtml( [ [ metricName, params[0].data[1] === null ? t('N/A') : yAxisFormatter.format(params[0].data[1]), ], ], formatTime(params[0].data[0]), ), }, aria: { enabled: true, label: { description: `Big number visualization ${subheader}`, }, }, useUTC: true, } : {}; const { onContextMenu } = hooks; return { width, height, bigNumber, // @ts-ignore bigNumberFallback, className, headerFormatter: yAxisFormatter, formatTime, formData, metricName: originalLabel, showMetricName, metricNameFontSize, headerFontSize, subtitleFontSize, subtitle, subheaderFontSize, mainColor, showTimestamp, showTrendLine, startYAxisAtZero, subheader: formattedSubheader, timestamp, trendLineData, echartOptions, onContextMenu, xValueFormatter: formatTime, refs, }; }