airbnb/superset

View on GitHub
superset-frontend/plugins/plugin-chart-echarts/src/Gauge/transformProps.ts

Summary

Maintainability
F
3 days
Test Coverage
/**
 * 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 {
  QueryFormMetric,
  CategoricalColorNamespace,
  CategoricalColorScale,
  DataRecord,
  getMetricLabel,
  getColumnLabel,
  getValueFormatter,
  tooltipHtml,
} from '@superset-ui/core';
import type { EChartsCoreOption } from 'echarts/core';
import type { GaugeSeriesOption } from 'echarts/charts';
import type { GaugeDataItemOption } from 'echarts/types/src/chart/gauge/GaugeSeries';
import type { CallbackDataParams } from 'echarts/types/src/util/types';
import { range } from 'lodash';
import { parseNumbersList } from '../utils/controls';
import {
  DEFAULT_FORM_DATA as DEFAULT_GAUGE_FORM_DATA,
  EchartsGaugeFormData,
  AxisTickLineStyle,
  GaugeChartTransformedProps,
  EchartsGaugeChartProps,
} from './types';
import {
  defaultGaugeSeriesOption,
  INTERVAL_GAUGE_SERIES_OPTION,
  OFFSETS,
  FONT_SIZE_MULTIPLIERS,
} from './constants';
import { OpacityEnum } from '../constants';
import { getDefaultTooltip } from '../utils/tooltip';
import { Refs } from '../types';
import { getColtypesMapping } from '../utils/series';

export const getIntervalBoundsAndColors = (
  intervals: string,
  intervalColorIndices: string,
  colorFn: CategoricalColorScale,
  min: number,
  max: number,
): Array<[number, string]> => {
  let intervalBoundsNonNormalized;
  let intervalColorIndicesArray;
  try {
    intervalBoundsNonNormalized = parseNumbersList(intervals, ',');
    intervalColorIndicesArray = parseNumbersList(intervalColorIndices, ',');
  } catch (error) {
    intervalBoundsNonNormalized = [] as number[];
    intervalColorIndicesArray = [] as number[];
  }

  const intervalBounds = intervalBoundsNonNormalized.map(
    bound => (bound - min) / (max - min),
  );
  const intervalColors = intervalColorIndicesArray.map(
    ind => colorFn.colors[(ind - 1) % colorFn.colors.length],
  );

  return intervalBounds.map((val, idx) => {
    const color = intervalColors[idx];
    return [val, color || colorFn.colors[idx]];
  });
};

const calculateAxisLineWidth = (
  data: DataRecord[],
  fontSize: number,
  overlap: boolean,
): number => (overlap ? fontSize : data.length * fontSize);

const calculateMin = (data: GaugeDataItemOption[]) =>
  2 * Math.min(...data.map(d => d.value as number).concat([0]));

const calculateMax = (data: GaugeDataItemOption[]) =>
  2 * Math.max(...data.map(d => d.value as number).concat([0]));

export default function transformProps(
  chartProps: EchartsGaugeChartProps,
): GaugeChartTransformedProps {
  const {
    width,
    height,
    formData,
    queriesData,
    hooks,
    filterState,
    theme,
    emitCrossFilters,
    datasource,
  } = chartProps;

  const gaugeSeriesOptions = defaultGaugeSeriesOption(theme);
  const {
    verboseMap = {},
    currencyFormats = {},
    columnFormats = {},
  } = datasource;
  const {
    groupby,
    metric,
    minVal,
    maxVal,
    colorScheme,
    fontSize,
    numberFormat,
    currencyFormat,
    animation,
    showProgress,
    overlap,
    roundCap,
    showAxisTick,
    showSplitLine,
    splitNumber,
    startAngle,
    endAngle,
    showPointer,
    intervals,
    intervalColorIndices,
    valueFormatter,
    sliceId,
  }: EchartsGaugeFormData = { ...DEFAULT_GAUGE_FORM_DATA, ...formData };
  const refs: Refs = {};
  const data = (queriesData[0]?.data || []) as DataRecord[];
  const coltypeMapping = getColtypesMapping(queriesData[0]);
  const numberFormatter = getValueFormatter(
    metric,
    currencyFormats,
    columnFormats,
    numberFormat,
    currencyFormat,
  );
  const colorFn = CategoricalColorNamespace.getScale(colorScheme as string);
  const axisLineWidth = calculateAxisLineWidth(data, fontSize, overlap);
  const groupbyLabels = groupby.map(getColumnLabel);
  const formatValue = (value: number) =>
    valueFormatter.replace('{value}', numberFormatter(value));
  const axisTickLength = FONT_SIZE_MULTIPLIERS.axisTickLength * fontSize;
  const splitLineLength = FONT_SIZE_MULTIPLIERS.splitLineLength * fontSize;
  const titleOffsetFromTitle =
    FONT_SIZE_MULTIPLIERS.titleOffsetFromTitle * fontSize;
  const detailOffsetFromTitle =
    FONT_SIZE_MULTIPLIERS.detailOffsetFromTitle * fontSize;
  const columnsLabelMap = new Map<string, string[]>();
  const metricLabel = getMetricLabel(metric as QueryFormMetric);

  const transformedData: GaugeDataItemOption[] = data.map(
    (data_point, index) => {
      const name = groupbyLabels
        .map(column => `${verboseMap[column] || column}: ${data_point[column]}`)
        .join(', ');
      columnsLabelMap.set(
        name,
        groupbyLabels.map(col => data_point[col] as string),
      );
      let item: GaugeDataItemOption = {
        value: data_point[metricLabel] as number,
        name,
        itemStyle: {
          color: colorFn(index, sliceId, colorScheme),
        },
        title: {
          offsetCenter: [
            '0%',
            `${index * titleOffsetFromTitle + OFFSETS.titleFromCenter}%`,
          ],
          fontSize,
        },
        detail: {
          offsetCenter: [
            '0%',
            `${
              index * titleOffsetFromTitle +
              OFFSETS.titleFromCenter +
              detailOffsetFromTitle
            }%`,
          ],
          fontSize: FONT_SIZE_MULTIPLIERS.detailFontSize * fontSize,
        },
      };
      if (
        filterState.selectedValues &&
        !filterState.selectedValues.includes(name)
      ) {
        item = {
          ...item,
          itemStyle: {
            color: colorFn(index, sliceId, colorScheme),
            opacity: OpacityEnum.SemiTransparent,
          },
          detail: {
            show: false,
          },
          title: {
            show: false,
          },
        };
      }
      return item;
    },
  );

  const { setDataMask = () => {}, onContextMenu } = hooks;

  const min = minVal ?? calculateMin(transformedData);
  const max = maxVal ?? calculateMax(transformedData);
  const axisLabels = range(min, max, (max - min) / splitNumber);
  const axisLabelLength = Math.max(
    ...axisLabels.map(label => numberFormatter(label).length).concat([1]),
  );
  const intervalBoundsAndColors = getIntervalBoundsAndColors(
    intervals,
    intervalColorIndices,
    colorFn,
    min,
    max,
  );
  const splitLineDistance =
    axisLineWidth + splitLineLength + OFFSETS.ticksFromLine;
  const axisLabelDistance =
    FONT_SIZE_MULTIPLIERS.axisLabelDistance *
      fontSize *
      FONT_SIZE_MULTIPLIERS.axisLabelLength *
      axisLabelLength +
    (showSplitLine ? splitLineLength : 0) +
    (showAxisTick ? axisTickLength : 0) +
    OFFSETS.ticksFromLine -
    axisLineWidth;
  const axisTickDistance =
    axisLineWidth + axisTickLength + OFFSETS.ticksFromLine;

  const progress = {
    show: showProgress,
    overlap,
    roundCap,
    width: fontSize,
  };
  const splitLine = {
    show: showSplitLine,
    distance: -splitLineDistance,
    length: splitLineLength,
    lineStyle: {
      width: FONT_SIZE_MULTIPLIERS.splitLineWidth * fontSize,
      color: gaugeSeriesOptions.splitLine?.lineStyle?.color,
    },
  };
  const axisLine = {
    roundCap,
    lineStyle: {
      width: axisLineWidth,
      color: gaugeSeriesOptions.axisLine?.lineStyle?.color,
    },
  };
  const axisLabel = {
    distance: -axisLabelDistance,
    fontSize,
    formatter: numberFormatter,
    color: gaugeSeriesOptions.axisLabel?.color,
  };
  const axisTick = {
    show: showAxisTick,
    distance: -axisTickDistance,
    length: axisTickLength,
    lineStyle: gaugeSeriesOptions.axisTick?.lineStyle as AxisTickLineStyle,
  };
  const detail = {
    valueAnimation: animation,
    formatter: (value: number) => formatValue(value),
    color: gaugeSeriesOptions.detail?.color,
  };
  const tooltip = {
    ...getDefaultTooltip(refs),
    formatter: (params: CallbackDataParams) => {
      const { name, value } = params;
      return tooltipHtml([[metricLabel, formatValue(value as number)]], name);
    },
  };

  let pointer;
  if (intervalBoundsAndColors.length) {
    splitLine.lineStyle.color =
      INTERVAL_GAUGE_SERIES_OPTION.splitLine?.lineStyle?.color;
    axisTick.lineStyle.color = INTERVAL_GAUGE_SERIES_OPTION?.axisTick?.lineStyle
      ?.color as string;
    axisLabel.color = INTERVAL_GAUGE_SERIES_OPTION.axisLabel?.color;
    axisLine.lineStyle.color = intervalBoundsAndColors;
    pointer = {
      show: showPointer,
      showAbove: false,
      itemStyle: INTERVAL_GAUGE_SERIES_OPTION.pointer?.itemStyle,
    };
  } else {
    pointer = {
      show: showPointer,
      showAbove: false,
    };
  }

  const series: GaugeSeriesOption[] = [
    {
      type: 'gauge',
      startAngle,
      endAngle,
      min,
      max,
      progress,
      animation,
      axisLine: axisLine as GaugeSeriesOption['axisLine'],
      splitLine,
      splitNumber,
      axisLabel,
      axisTick,
      pointer,
      detail,
      // @ts-ignore
      tooltip,
      radius:
        Math.min(width, height) / 2 - axisLabelDistance - axisTickDistance,
      center: ['50%', '55%'],
      data: transformedData,
    },
  ];

  const echartOptions: EChartsCoreOption = {
    tooltip: {
      ...getDefaultTooltip(refs),
      trigger: 'item',
    },
    series,
  };

  return {
    formData,
    width,
    height,
    echartOptions,
    setDataMask,
    emitCrossFilters,
    labelMap: Object.fromEntries(columnsLabelMap),
    groupby,
    selectedValues: filterState.selectedValues || [],
    onContextMenu,
    refs,
    coltypeMapping,
  };
}