airbnb/superset

View on GitHub
superset-frontend/plugins/plugin-chart-echarts/src/BoxPlot/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 {
  CategoricalColorNamespace,
  getColumnLabel,
  getMetricLabel,
  getNumberFormatter,
  getTimeFormatter,
} from '@superset-ui/core';
import type { EChartsCoreOption } from 'echarts/core';
import type { BoxplotSeriesOption } from 'echarts/charts';
import type { CallbackDataParams } from 'echarts/types/src/util/types';
import {
  BoxPlotChartTransformedProps,
  BoxPlotQueryFormData,
  EchartsBoxPlotChartProps,
} from './types';
import {
  extractGroupbyLabel,
  getColtypesMapping,
  sanitizeHtml,
} from '../utils/series';
import { convertInteger } from '../utils/convertInteger';
import { defaultGrid, defaultYAxis } from '../defaults';
import { getPadding } from '../Timeseries/transformers';
import { OpacityEnum } from '../constants';
import { getDefaultTooltip } from '../utils/tooltip';
import { Refs } from '../types';

export default function transformProps(
  chartProps: EchartsBoxPlotChartProps,
): BoxPlotChartTransformedProps {
  const {
    width,
    height,
    formData,
    hooks,
    filterState,
    queriesData,
    inContextMenu,
    emitCrossFilters,
  } = chartProps;
  const { data = [] } = queriesData[0];
  const { setDataMask = () => {}, onContextMenu } = hooks;
  const coltypeMapping = getColtypesMapping(queriesData[0]);
  const {
    colorScheme,
    groupby = [],
    metrics = [],
    numberFormat,
    dateFormat,
    xTicksLayout,
    legendOrientation = 'top',
    xAxisTitle,
    yAxisTitle,
    xAxisTitleMargin,
    yAxisTitleMargin,
    yAxisTitlePosition,
    sliceId,
  } = formData as BoxPlotQueryFormData;
  const refs: Refs = {};
  const colorFn = CategoricalColorNamespace.getScale(colorScheme as string);
  const numberFormatter = getNumberFormatter(numberFormat);
  const metricLabels = metrics.map(getMetricLabel);
  const groupbyLabels = groupby.map(getColumnLabel);

  const transformedData = data
    .map((datum: any) => {
      const groupbyLabel = extractGroupbyLabel({
        datum,
        groupby: groupbyLabels,
        coltypeMapping,
        timeFormatter: getTimeFormatter(dateFormat),
      });
      return metricLabels.map(metric => {
        const name =
          metricLabels.length === 1
            ? groupbyLabel
            : `${groupbyLabel}, ${metric}`;
        const isFiltered =
          filterState.selectedValues &&
          !filterState.selectedValues.includes(name);
        return {
          name,
          value: [
            datum[`${metric}__min`],
            datum[`${metric}__q1`],
            datum[`${metric}__median`],
            datum[`${metric}__q3`],
            datum[`${metric}__max`],
            datum[`${metric}__mean`],
            datum[`${metric}__count`],
            datum[`${metric}__outliers`],
          ],
          itemStyle: {
            color: colorFn(groupbyLabel, sliceId, colorScheme),
            opacity: isFiltered ? OpacityEnum.SemiTransparent : 0.6,
            borderColor: colorFn(groupbyLabel, sliceId, colorScheme),
          },
        };
      });
    })
    .flatMap(row => row);
  const outlierData = data
    .map(datum =>
      metricLabels.map(metric => {
        const groupbyLabel = extractGroupbyLabel({
          datum,
          groupby: groupbyLabels,
          coltypeMapping,
          timeFormatter: getTimeFormatter(dateFormat),
        });
        const name =
          metricLabels.length === 1
            ? groupbyLabel
            : `${groupbyLabel}, ${metric}`;
        // Outlier data is a nested array of numbers (uncommon, therefore no need to add to DataRecordValue)
        const outlierDatum = (datum[`${metric}__outliers`] || []) as number[];
        const isFiltered =
          filterState.selectedValues &&
          !filterState.selectedValues.includes(name);
        return {
          name: 'outlier',
          type: 'scatter',
          data: outlierDatum.map(val => [name, val]),
          tooltip: {
            ...getDefaultTooltip(refs),
            formatter: (param: { data: [string, number] }) => {
              const [outlierName, stats] = param.data;
              const headline = groupbyLabels.length
                ? `<p><strong>${sanitizeHtml(outlierName)}</strong></p>`
                : '';
              return `${headline}${numberFormatter(stats)}`;
            },
          },
          itemStyle: {
            color: colorFn(groupbyLabel, sliceId, colorScheme),
            opacity: isFiltered
              ? OpacityEnum.SemiTransparent
              : OpacityEnum.NonTransparent,
          },
        };
      }),
    )
    .flat(2);

  const labelMap = data.reduce((acc: Record<string, string[]>, datum) => {
    const label = extractGroupbyLabel({
      datum,
      groupby: groupbyLabels,
      coltypeMapping,
      timeFormatter: getTimeFormatter(dateFormat),
    });
    return {
      ...acc,
      [label]: groupbyLabels.map(col => datum[col] as string),
    };
  }, {});

  const selectedValues = (filterState.selectedValues || []).reduce(
    (acc: Record<string, number>, selectedValue: string) => {
      const index = transformedData.findIndex(
        ({ name }) => name === selectedValue,
      );
      return {
        ...acc,
        [index]: selectedValue,
      };
    },
    {},
  );

  let axisLabel;
  if (xTicksLayout === '45°') axisLabel = { rotate: -45 };
  else if (xTicksLayout === '90°') axisLabel = { rotate: -90 };
  else if (xTicksLayout === 'flat') axisLabel = { rotate: 0 };
  else if (xTicksLayout === 'staggered') axisLabel = { rotate: -45 };
  else axisLabel = { show: true };

  const series: BoxplotSeriesOption[] = [
    {
      name: 'boxplot',
      type: 'boxplot',
      data: transformedData,
      tooltip: {
        ...getDefaultTooltip(refs),
        formatter: (param: CallbackDataParams) => {
          // @ts-ignore
          const {
            value,
            name,
          }: {
            value: [
              number,
              number,
              number,
              number,
              number,
              number,
              number,
              number,
              number[],
            ];
            name: string;
          } = param;
          const headline = name
            ? `<p><strong>${sanitizeHtml(name)}</strong></p>`
            : '';
          const stats = [
            `Max: ${numberFormatter(value[5])}`,
            `3rd Quartile: ${numberFormatter(value[4])}`,
            `Mean: ${numberFormatter(value[6])}`,
            `Median: ${numberFormatter(value[3])}`,
            `1st Quartile: ${numberFormatter(value[2])}`,
            `Min: ${numberFormatter(value[1])}`,
            `# Observations: ${numberFormatter(value[7])}`,
          ];
          if (value[8].length > 0) {
            stats.push(`# Outliers: ${numberFormatter(value[8].length)}`);
          }
          return headline + stats.join('<br/>');
        },
      },
    },
    // @ts-ignore
    ...outlierData,
  ];
  const addYAxisTitleOffset = !!yAxisTitle;
  const addXAxisTitleOffset = !!xAxisTitle;
  const chartPadding = getPadding(
    true,
    legendOrientation,
    addYAxisTitleOffset,
    false,
    null,
    addXAxisTitleOffset,
    yAxisTitlePosition,
    convertInteger(yAxisTitleMargin),
    convertInteger(xAxisTitleMargin),
  );
  const echartOptions: EChartsCoreOption = {
    grid: {
      ...defaultGrid,
      ...chartPadding,
    },
    xAxis: {
      type: 'category',
      data: transformedData.map(row => row.name),
      axisLabel,
      name: xAxisTitle,
      nameGap: convertInteger(xAxisTitleMargin),
      nameLocation: 'middle',
    },
    yAxis: {
      ...defaultYAxis,
      type: 'value',
      axisLabel: { formatter: numberFormatter },
      name: yAxisTitle,
      nameGap: convertInteger(yAxisTitleMargin),
      nameLocation: yAxisTitlePosition === 'Left' ? 'middle' : 'end',
    },
    tooltip: {
      ...getDefaultTooltip(refs),
      show: !inContextMenu,
      trigger: 'item',
      axisPointer: {
        type: 'shadow',
      },
    },
    series,
  };

  return {
    formData,
    width,
    height,
    echartOptions,
    setDataMask,
    emitCrossFilters,
    labelMap,
    groupby,
    selectedValues,
    onContextMenu,
    refs,
    coltypeMapping,
  };
}