Bootstrap

Superset BI封装自定义组件(堆叠柱状图)

目录

前言

封装步骤

一、创建组件文件夹

二、预设组件信息

三、使用组件

往期回顾


前言

Superset 是一个现代化的、易于使用的、轻量级的数据可视化工具,它允许用户通过简单的点击操作来创建和分享图表。如果你想在 Superset 中创建自定义组件,你可能需要进行一些扩展工作。

封装步骤

一、创建组件文件夹

组件的目录结构

1. 新组件文件夹创建目录在superset/superset-frontend/src/visualizations目录下创建新组件文件夹

2. 文件夹内容及用途介绍

images文件夹:存放组件的缩略图

buildQuery.ts:定义查询数据的方式

源码:

import {
  buildQueryContext,
  ensureIsArray,
  getMetricLabel,
  QueryMode,
  QueryObject,
  removeDuplicates,
  SetDataMaskHook,
} from '@superset-ui/core';
import { PostProcessingRule } from '@superset-ui/core/src/query/types/PostProcessing';
import { BuildQuery } from '@superset-ui/core/src/chart/registries/ChartBuildQueryRegistrySingleton';
import { TableChartFormData } from './types';

/**
 * Infer query mode from form data. If `all_columns` is set, then raw records mode,
 * otherwise defaults to aggregation mode.
 *
 * The same logic is used in `controlPanel` with control values as well.
 */

export function getQueryMode(formData: TableChartFormData) {
  const { query_mode: mode } = formData;
  if (mode === QueryMode.aggregate || mode === QueryMode.raw) {
    return mode;
  }
  const rawColumns = formData?.all_columns;
  const hasRawColumns = rawColumns && rawColumns.length > 0;
  return hasRawColumns ? QueryMode.raw : QueryMode.aggregate;
}

const buildQuery: BuildQuery<TableChartFormData> = (
  formData: TableChartFormData,
  options,
) => {
  const { percent_metrics: percentMetrics, order_desc: orderDesc = false } =
    formData;
  const queryMode = getQueryMode(formData);
  const sortByMetric = ensureIsArray(formData.timeseries_limit_metric)[0];
  let formDataCopy = formData;
  // never include time in raw records mode
  if (queryMode === QueryMode.raw) {
    formDataCopy = {
      ...formData,
      include_time: false,
    };
  }

  const updateExternalFormData = (
    setDataMask: SetDataMaskHook = () => {},
    pageNumber: number,
    pageSize: number,
  ) =>
    setDataMask({
      ownState: {
        currentPage: pageNumber,
        pageSize,
      },
    });

  return buildQueryContext(formDataCopy, baseQueryObject => {
    let { metrics, orderby = [] } = baseQueryObject;
    let postProcessing: PostProcessingRule[] = [];

    if (queryMode === QueryMode.aggregate) {
      metrics = metrics || [];
      // orverride orderby with timeseries metric when in aggregation mode
      if (sortByMetric) {
        orderby = [[sortByMetric, !orderDesc]];
      } else if (metrics?.length > 0) {
        // default to ordering by first metric in descending order
        // when no "sort by" metric is set (regargless if "SORT DESC" is set to true)
        orderby = [[metrics[0], false]];
      }
      // add postprocessing for percent metrics only when in aggregation mode
      if (percentMetrics && percentMetrics.length > 0) {
        const percentMetricLabels = removeDuplicates(
          percentMetrics.map(getMetricLabel),
        );
        metrics = removeDuplicates(
          metrics.concat(percentMetrics),
          getMetricLabel,
        );
        postProcessing = [
          {
            operation: 'contribution',
            options: {
              columns: percentMetricLabels,
              rename_columns: percentMetricLabels.map(x => `%${x}`),
            },
          },
        ];
      }
    }

    const moreProps: Partial<QueryObject> = {};
    const ownState = options?.ownState ?? {};
    if (formDataCopy.server_pagination) {
      moreProps.row_limit =
        ownState.pageSize ?? formDataCopy.server_page_length;
      moreProps.row_offset =
        (ownState.currentPage ?? 0) * (ownState.pageSize ?? 0);
    }

    let queryObject = {
      ...baseQueryObject,
      orderby,
      metrics,
      post_processing: postProcessing,
      ...moreProps,
    };

    if (
      formData.server_pagination &&
      options?.extras?.cachedChanges?.[formData.slice_id] &&
      JSON.stringify(options?.extras?.cachedChanges?.[formData.slice_id]) !==
        JSON.stringify(queryObject.filters)
    ) {
      queryObject = { ...queryObject, row_offset: 0 };
      updateExternalFormData(
        options?.hooks?.setDataMask,
        0,
        queryObject.row_limit ?? 0,
      );
    }
    // Because we use same buildQuery for all table on the page we need split them by id
    options?.hooks?.setCachedChanges({
      [formData.slice_id]: queryObject.filters,
    });

    const interactiveGroupBy = formData.extra_form_data?.interactive_groupby;
    if (interactiveGroupBy && queryObject.columns) {
      queryObject.columns = [
        ...new Set([...queryObject.columns, ...interactiveGroupBy]),
      ];
    }

    if (formData.server_pagination) {
      return [
        { ...queryObject },
        {
          ...queryObject,
          row_limit: 0,
          row_offset: 0,
          post_processing: [],
          is_rowcount: true,
        },
      ];
    }
    return [queryObject];
  });
};

// Use this closure to cache changing of external filters, if we have server pagination we need reset page to 0, after
// external filter changed
export const cachedBuildQuery = (): BuildQuery<TableChartFormData> => {
  let cachedChanges: any = {};
  const setCachedChanges = (newChanges: any) => {
    cachedChanges = { ...cachedChanges, ...newChanges };
  };

  return (formData, options) =>
    buildQuery(
      { ...formData },
      {
        extras: { cachedChanges },
        ownState: options?.ownState ?? {},
        hooks: {
          ...options?.hooks,
          setDataMask: () => {},
          setCachedChanges,
        },
      },
    );
};

export default cachedBuildQuery();

controlPanel.tsx:superset左侧的面板信息配置项

左侧面板配置信息:

源码: 

import React from 'react';
import {
  ChartDataResponseResult,
  ensureIsArray,
  FeatureFlag,
  GenericDataType,
  isFeatureEnabled,
  QueryFormColumn,
  QueryMode,
  smartDateFormatter,
  t,
} from '@superset-ui/core';
import {
  ColumnOption,
  ControlConfig,
  ControlPanelConfig,
  ControlPanelsContainerProps,
  ControlStateMapping,
  D3_TIME_FORMAT_OPTIONS,
  QueryModeLabel,
  sections,
  sharedControls,
  ControlPanelState,
  ExtraControlProps,
  ControlState,
  emitFilterControl,
  formatSelectOptions,
} from '@superset-ui/chart-controls';

const PAGE_SIZE_OPTIONS = formatSelectOptions<number>([
  [0, t('page_size.all')],
  10,
  20,
  50,
  100,
  200,
]);

function getQueryMode(controls: ControlStateMapping): QueryMode {
  const mode = controls?.query_mode?.value;
  if (mode === QueryMode.aggregate || mode === QueryMode.raw) {
    return mode as QueryMode;
  }
  const rawColumns = controls?.all_columns?.value as
    | QueryFormColumn[]
    | undefined;
  const hasRawColumns = rawColumns && rawColumns.length > 0;
  return hasRawColumns ? QueryMode.raw : QueryMode.aggregate;
}

/**
 * Visibility check
 */
function isQueryMode(mode: QueryMode) {
  return ({ controls }: Pick<ControlPanelsContainerProps, 'controls'>) =>
    getQueryMode(controls) === mode;
}

const isAggMode = isQueryMode(QueryMode.aggregate);
const isRawMode = isQueryMode(QueryMode.raw);

const validateAggControlValues = (
  controls: ControlStateMapping,
  values: any[],
) => {
  const areControlsEmpty = values.every(val => ensureIsArray(val).length === 0);
  return areControlsEmpty && isAggMode({ controls })
    ? [t('Group By, Metrics or Percentage Metrics must have a value')]
    : [];
};

const queryMode: ControlConfig<'RadioButtonControl'> = {
  type: 'RadioButtonControl',
  label: t('Query mode'),
  default: null,
  options: [
    [QueryMode.aggregate, QueryModeLabel[QueryMode.aggregate]],
    [QueryMode.raw, QueryModeLabel[QueryMode.raw]],
  ],
  mapStateToProps: ({ controls }) => ({ value: getQueryMode(controls) }),
  rerender: ['all_columns', 'groupby', 'metrics', 'percent_metrics'],
};

const all_columns: typeof sharedControls.groupby = {
  type: 'SelectControl',
  label: t('Columns'),
  description: t('Columns to display'),
  multi: true,
  freeForm: true,
  allowAll: true,
  commaChoosesOption: false,
  default: [],
  optionRenderer: c => <ColumnOption showType column={c} />,
  valueRenderer: c => <ColumnOption column={c} />,
  valueKey: 'column_name',
  mapStateToProps: ({ datasource, controls }, controlState) => ({
    options: datasource?.columns || [],
    queryMode: getQueryMode(controls),
    externalValidationErrors:
      isRawMode({ controls }) && ensureIsArray(controlState.value).length === 0
        ? [t('must have a value')]
        : [],
  }),
  sortComparator: (a: { label: string }, b: { label: string }) =>
    a.label.localeCompare(b.label),
  visibility: isRawMode,
};

const dnd_all_columns: typeof sharedControls.groupby = {
  type: 'DndColumnSelect',
  label: t('Columns'),
  description: t('Columns to display'),
  default: [],
  mapStateToProps({ datasource, controls }, controlState) {
    const newState: ExtraControlProps = {};
    if (datasource) {
      const options = datasource.columns;
      newState.options = Object.fromEntries(
        options.map(option => [option.column_name, option]),
      );
    }
    newState.queryMode = getQueryMode(controls);
    newState.externalValidationErrors =
      isRawMode({ controls }) && ensureIsArray(controlState.value).length === 0
        ? [t('must have a value')]
        : [];
    return newState;
  },
  visibility: isRawMode,
};

const percent_metrics: typeof sharedControls.metrics = {
  type: 'MetricsControl',
  label: t('Percentage metrics'),
  description: t(
    'Metrics for which percentage of total are to be displayed. Calculated from only data within the row limit.',
  ),
  multi: true,
  visibility: isAggMode,
  mapStateToProps: ({ datasource, controls }, controlState) => ({
    columns: datasource?.columns || [],
    savedMetrics: datasource?.metrics || [],
    datasource,
    datasourceType: datasource?.type,
    queryMode: getQueryMode(controls),
    externalValidationErrors: validateAggControlValues(controls, [
      controls.groupby?.value,
      controls.metrics?.value,
      controlState.value,
    ]),
  }),
  rerender: ['groupby', 'metrics'],
  default: [],
  validators: [],
};

const dnd_percent_metrics = {
  ...percent_metrics,
  type: 'DndMetricSelect',
};

const config: ControlPanelConfig = {
  controlPanelSections: [
    sections.legacyTimeseriesTime,
    {
      label: t('Query'),
      expanded: true,
      controlSetRows: [
        [
          {
            name: 'query_mode',
            config: queryMode,
          },
        ],
        [
          {
            name: 'groupby',
            override: {
              visibility: isAggMode,
              mapStateToProps: (
                state: ControlPanelState,
                controlState: ControlState,
              ) => {
                const { controls } = state;
                const originalMapStateToProps =
                  sharedControls?.groupby?.mapStateToProps;
                const newState =
                  originalMapStateToProps?.(state, controlState) ?? {};
                newState.externalValidationErrors = validateAggControlValues(
                  controls,
                  [
                    controls.metrics?.value,
                    controls.percent_metrics?.value,
                    controlState.value,
                  ],
                );

                return newState;
              },
              rerender: ['metrics', 'percent_metrics'],
            },
          },
        ],
        [
          {
            name: 'metrics',
            override: {
              validators: [],
              visibility: isAggMode,
              mapStateToProps: (
                { controls, datasource, form_data }: ControlPanelState,
                controlState: ControlState,
              ) => ({
                columns: datasource?.columns.filter(c => c.filterable) || [],
                savedMetrics: datasource?.metrics || [],
                // current active adhoc metrics
                selectedMetrics:
                  form_data.metrics ||
                  (form_data.metric ? [form_data.metric] : []),
                datasource,
                externalValidationErrors: validateAggControlValues(controls, [
                  controls.groupby?.value,
                  controls.percent_metrics?.value,
                  controlState.value,
                ]),
              }),
              rerender: ['groupby', 'percent_metrics'],
            },
          },
          {
            name: 'all_columns',
            config: isFeatureEnabled(FeatureFlag.ENABLE_EXPLORE_DRAG_AND_DROP)
              ? dnd_all_columns
              : all_columns,
          },
        ],
        [
          {
            name: 'percent_metrics',
            config: {
              ...(isFeatureEnabled(FeatureFlag.ENABLE_EXPLORE_DRAG_AND_DROP)
                ? dnd_percent_metrics
                : percent_metrics),
            },
          },
        ],
        ['adhoc_filters'],
        [
          {
            name: 'timeseries_limit_metric',
            override: {
              visibility: isAggMode,
            },
          },
          {
            name: 'order_by_cols',
            config: {
              type: 'SelectControl',
              label: t('Ordering'),
              description: t('Order results by selected columns'),
              multi: true,
              default: [],
              mapStateToProps: ({ datasource }) => ({
                choices: datasource?.order_by_choices || [],
              }),
              visibility: isRawMode,
              sortComparator: (a: { label: string }, b: { label: string }) =>
                a.label.localeCompare(b.label),
            },
          },
        ],
        isFeatureEnabled(FeatureFlag.DASHBOARD_CROSS_FILTERS) ||
        isFeatureEnabled(FeatureFlag.DASHBOARD_NATIVE_FILTERS)
          ? [
              {
                name: 'server_pagination',
                config: {
                  type: 'CheckboxControl',
                  label: t('Server pagination'),
                  description: t(
                    'Enable server side pagination of results (experimental feature)',
                  ),
                  default: false,
                },
              },
            ]
          : [],
        [
          {
            name: 'row_limit',
            override: {
              visibility: ({ controls }: ControlPanelsContainerProps) =>
                !controls?.server_pagination?.value,
            },
          },
          {
            name: 'server_page_length',
            config: {
              type: 'SelectControl',
              freeForm: true,
              label: t('Server Page Length'),
              default: 10,
              choices: PAGE_SIZE_OPTIONS,
              description: t('Rows per page, 0 means no pagination'),
              visibility: ({ controls }: ControlPanelsContainerProps) =>
                Boolean(controls?.server_pagination?.value),
            },
          },
        ],
        [
          {
            name: 'include_time',
            config: {
              type: 'CheckboxControl',
              label: t('Include time'),
              description: t(
                'Whether to include the time granularity as defined in the time section',
              ),
              default: false,
              visibility: isAggMode,
            },
          },
          {
            name: 'order_desc',
            config: {
              type: 'CheckboxControl',
              label: t('Sort descending'),
              default: true,
              description: t('Whether to sort descending or ascending'),
              visibility: isAggMode,
            },
          },
        ],
        emitFilterControl,
        [
          {
            name: 'funnelUnit',
            config: {
              type: 'TextControl',
              label: t('funnelUnit'),
              default: '个',
              renderTrigger: true,
              description: t('funnelUnit'),
            },
          },
        ],
        [
          {
            name: 'subheader',
            config: {
              type: 'TextControl',
              label: t('Title'),
              renderTrigger: true,
              description: t('Title'),
            },
          },
        ],
        [
          {
            name: 'tips',
            config: {
              type: 'TextControl',
              label: t('Tips'),
              renderTrigger: true,
              description: t('tips'),
            },
          },
        ],
        [
          {
            name: 'groupMertic',
            config: {
              type: 'TextControl',
              label: '分组指标',
              renderTrigger: true,
            },
          },
        ],
        [
          {
            name: 'countMertic',
            config: {
              type: 'TextControl',
              label: '计数指标',
              renderTrigger: true,
            },
          },
        ],
      ],
    },
    {
      label: t('Options'),
      expanded: true,
      controlSetRows: [
        [
          {
            name: 'table_timestamp_format',
            config: {
              type: 'SelectControl',
              freeForm: true,
              label: t('Timestamp format'),
              default: smartDateFormatter.id,
              renderTrigger: true,
              clearable: false,
              choices: D3_TIME_FORMAT_OPTIONS,
              description: t('D3 time format for datetime columns'),
            },
          },
        ],
        [
          {
            name: 'page_length',
            config: {
              type: 'SelectControl',
              freeForm: true,
              renderTrigger: true,
              label: t('Page length'),
              default: null,
              choices: PAGE_SIZE_OPTIONS,
              description: t('Rows per page, 0 means no pagination'),
              visibility: ({ controls }: ControlPanelsContainerProps) =>
                !controls?.server_pagination?.value,
            },
          },
          null,
        ],
        [
          {
            name: 'include_search',
            config: {
              type: 'CheckboxControl',
              label: t('Search box'),
              renderTrigger: true,
              default: false,
              description: t('Whether to include a client-side search box'),
            },
          },
          {
            name: 'show_cell_bars',
            config: {
              type: 'CheckboxControl',
              label: t('Cell bars'),
              renderTrigger: true,
              default: false,
              description: t(
                'Whether to display a bar chart background in table columns',
              ),
            },
          },
        ],
        [
          {
            name: 'align_pn',
            config: {
              type: 'CheckboxControl',
              label: t('Align +/-'),
              renderTrigger: true,
              default: false,
              description: t(
                'Whether to align background charts with both positive and negative values at 0',
              ),
            },
          },
          {
            name: 'color_pn',
            config: {
              type: 'CheckboxControl',
              label: t('Color +/-'),
              renderTrigger: true,
              default: true,
              description: t(
                'Whether to colorize numeric values by if they are positive or negative',
              ),
            },
          },
        ],
        [
          {
            name: 'column_config',
            config: {
              type: 'ColumnConfigControl',
              label: t('Customize columns'),
              description: t('Further customize how to display each column'),
              renderTrigger: true,
              mapStateToProps(explore, control, chart) {
                return {
                  queryResponse: chart?.queriesResponse?.[0] as
                    | ChartDataResponseResult
                    | undefined,
                  emitFilter: explore?.controls?.table_filter?.value,
                };
              },
            },
          },
        ],
        [
          {
            name: 'conditional_formatting',
            config: {
              type: 'ConditionalFormattingControl',
              renderTrigger: true,
              label: t('Conditional formatting'),
              description: t(
                'Apply conditional color formatting to numeric columns',
              ),
              mapStateToProps(explore, control, chart) {
                const verboseMap = explore?.datasource?.verbose_map ?? {};
                const { colnames, coltypes } =
                  chart?.queriesResponse?.[0] ?? {};
                const numericColumns =
                  Array.isArray(colnames) && Array.isArray(coltypes)
                    ? colnames
                        .filter(
                          (colname: string, index: number) =>
                            coltypes[index] === GenericDataType.NUMERIC,
                        )
                        .map(colname => ({
                          value: colname,
                          label: verboseMap[colname] ?? colname,
                        }))
                    : [];
                return {
                  columnOptions: numericColumns,
                  verboseMap,
                };
              },
            },
          },
        ],
      ],
    },
  ],
};

export default config;

StackBarChart.tsx:组件的内容

源码:

import React from 'react';
import * as echarts from 'echarts';
import { dealMetricValue, downloadAsImage } from '../FunnelChart/utils';
import '../BarChart/BarStyle.less';

type StackBarChartVisProps = {
  chartId: string;
  unitName: string;
  xData: [];
  yData: [];
  legendData: [];
  tips: string;
  subheader: string;
  width: number;
  height: number;
};

export default class StackBarChart extends React.PureComponent<StackBarChartVisProps> {
  static defaultProps = {
    chartId: '',
    unitName: '',
    xData: [],
    yData: [],
    legendData: [],
    tips: '',
    subheader: '',
    width: 0,
    height: 0,
  };

  createTemporaryContainer() {
    const container = document.createElement('div');
    container.style.position = 'absolute'; // so it won't disrupt page layout
    container.style.opacity = '0'; // and not visible
    return container;
  }

  showIcon(e: any) {
    const subHeader = $(e.currentTarget).parent('.subheader-chart-line');
    const ele = subHeader.siblings('.tips-container');
    const iconLeft = subHeader.find('span').css('width');
    const blockLeft = Number(iconLeft.slice(0, -2)) + 10;
    ele.find('.blockIcon').css('left', `${blockLeft}px`);
    ele.toggle();
  }

  download() {
    const { subheader, chartId } = this.props;
    const selector = `#${chartId}`;
    downloadAsImage(selector, subheader);
  }

  setChartOption() {
    const {
      chartId,
      xData,
      yData,
      legendData,
      unitName = '',
    } = { ...this.props };
    const dom = document.getElementById(chartId);
    let mychart;
    if (dom) {
      mychart = echarts.init(dom);
    }
    const colorList = ['#0080FF', '#ff5a39', '#5ae1dc', '#ffae00', '#af52de'];
    const comSeries = {
      type: 'bar',
      stack: 'Ad',
      barWidth: 40,
    };
    const comLegend = {
      icon: 'circle',
      itemGap: 16,
      inactiveColor: '#999999',
      orient: 'horizontal',
      itemHeight: 10,
      top: 10,
      formatter: (name: any) => `{a|${name}}`,
      textStyle: {
        rich: {
          a: {
            color: 'rgba(0,0,0,0.45)',
            fontSize: 14,
          },
        },
      },
    };
    const seriesData = [];
    for (let i = 0; i < yData.length; i += 1) {
      const obj = {
        name: legendData[i],
        ...comSeries,
        itemStyle: {
          color: colorList[i],
        },
        data: yData[i],
      };
      seriesData.push(obj);
    }

    const root = document.getElementById('app');
    let username: any = '';
    const watermark = root ? root.getAttribute('watermark') : 'true';
    if (watermark === 'true') {
      username = root ? root.getAttribute('nickname') : 'uos';
      username = username !== null ? username : 'uos';
    }
    const waterMarkText = username;
    const canvas = document.createElement('canvas');
    const ctx = canvas.getContext('2d');
    canvas.width = canvas.height = 100;
    if (ctx) {
      ctx.textAlign = 'center';
      ctx.textBaseline = 'middle';
      ctx.globalAlpha = 0.08;
      ctx.font = '20px Microsoft Yahei';
      ctx.translate(50, 50);
      ctx.rotate(-Math.PI / 4);
      ctx.fillText(waterMarkText, 0, 0);
    }

    const option: any = {
      backgroundColor: {
        type: 'pattern',
        image: canvas,
        repeat: 'repeat',
      },
      legend: {
        ...comLegend,
        data: legendData,
      },
      tooltip: {
        appendToBody: true,
        backgroundColor: 'rgba(0,0,0,0.6)',
        position: (point: any, params = '', dom: any, rect: any, size: any) => {
          const ele = dom;
          ele.style.transform = 'translateZ(0)';
          const distance = point[0] - Math.ceil(size.contentSize[0] / 2);
          return { left: distance, top: 0 };
        },
        trigger: 'axis',
        axisPointer: {
          lineStyle: {
            type: 'dashed',
            color: 'rgba(0,0,0,0.2)',
          },
        },
        textStyle: {
          color: '#FFF',
          fontSize: 14,
        },
      },
      formatter(ele: any) {
        const Object = ele;
        const aValue = Object[0].axisValue;
        let result = `${aValue}<br>`;
        for (let index = 0; index < Object.length; index += 1) {
          const { marker = '', seriesName = '' } = { ...Object[index] };
          const value = Number(Object[index].data);
          const data = unitName === '%' ? value.toFixed(2) : value;
          result += `${marker}${seriesName}:${dealMetricValue(
            data,
          )}${unitName}<br>`;
        }
        return result;
      },
      grid: {
        containLabel: true,
        left: 16,
        right: 20,
        top: 100,
        bottom: 30,
      },
      xAxis: {
        type: 'category',
        axisLabel: {
          showMinLabel: true,
          showMaxLabel: true,
          fontSize: 14,
          color: '#00000073',
        },
        axisTick: {
          lineStyle: {
            color: '#e5e5e5',
            width: 2,
          },
        },
        axisLine: {
          lineStyle: {
            color: '#e5e5e5',
            width: 2,
          },
        },
        data: xData,
      },
      yAxis: {
        type: 'value',
        axisLabel: {
          formatter: `{value}${unitName === '%' ? unitName : ''}`,
          fontSize: 14,
          color: '#00000073',
          margin: 8,
        },
        splitLine: {
          lineStyle: {
            type: 'dashed',
            color: '#00000014',
          },
        },
        axisTick: {
          show: false,
        },
        axisLine: {
          show: false,
        },
      },
      series: seriesData,
    };
    if (mychart) {
      mychart.clear();
      mychart.setOption(option);
    }
  }

  renderTips() {
    const { tips } = this.props;
    const container = this.createTemporaryContainer();
    document.body.append(container);
    container.remove();
    return (
      <div className="tips-container hideContainer">
        <a> {tips} </a>
        <span className="blockIcon">{}</span>
      </div>
    );
  }

  renderLoadBtn() {
    return (
      <button type="button" className="loadBtn" onClick={() => this.download()}>
        导出
      </button>
    );
  }

  renderSubheader() {
    const { subheader, unitName, tips } = this.props;
    const text = subheader;
    if (text) {
      const container = this.createTemporaryContainer();
      document.body.append(container);
      container.remove();
      let iconMessage;
      if (tips) {
        iconMessage = (
          <a role="button" tabIndex={0} onClick={e => this.showIcon(e)}>
            <img
              src="/static/assets/images/help-icon.png"
              alt="helpIcon"
              className="helpIcon"
            />
          </a>
        );
      }
      return (
        <div className="subheader-chart-line">
          <span> {text} </span>
          {iconMessage}
          <span>
            {unitName === '%' ? `(单位:百分比)` : `(单位:${unitName})`}
          </span>
        </div>
      );
    }
    return null;
  }

  componentDidMount() {
    this.setChartOption();
  }

  componentDidUpdate() {
    this.setChartOption();
  }

  renderContent() {
    const { chartId, width, height } = { ...this.props };
    return (
      <div
        id={chartId}
        style={{ width: `${width}px`, height: `${height - 20}px` }}
      >
        {}
      </div>
    );
  }

  render() {
    return (
      <div>
        {this.renderTips()}
        {this.renderSubheader()}
        {this.renderContent()}
        {this.renderLoadBtn()}
      </div>
    );
  }
}

BarStyle.less 

.bar-container {
  position: absolute;
  width: 100%;
  height: 100%;
}

.bar-content {
  position: relative;
  height: 30px;
  margin-top: 16px;
  span {
    position: absolute;
    left: 16px;
    color: rgba(0, 0, 0, 0.45);
    font-size: 14px;
  }
  > a {
    position: absolute;
    right: 0;
    color: rgba(0, 0, 0, 0.85);
    font-size: 14px;
    text-decoration: none;
  }
  .bar-progress {
    position: absolute;
    top: 22px;
    left: 16px;
    height: 10px;
    width: calc(100% - 16px);
    background: rgba(0, 0, 0, 0.05);
    a {
      position: absolute;
      height: 100%;
      background: #0080ff;
    }
  }
}
.hideContainer {
  display: none;
}

.tips-container {
  position: absolute;
  width: 300px;
  top: 30px;
  padding: 2px 8px;
  min-height: 40px;
  background: rgba(0, 0, 0, 0.85);
  border-radius: 4px;
  z-index: 10;
  a {
    color: #ffffff;
    font-size: 12px;
    text-decoration: none;
  }
  .blockIcon {
    display: inline-block;
    position: absolute;
    top: -19.5px;
    border-top: 10px solid transparent;
    border-left: 10px solid transparent;
    border-right: 10px solid transparent;
    border-bottom: 10px solid rgba(0, 0, 0, 0.85);
  }
}
.subheader-chart-line {
  font-size: 16px;
  line-height: 20px;
  padding-bottom: 0;
  padding-bottom: 15px;
  border-bottom: 1px solid rgba(0, 0, 0, 0.1);
  img {
    width: 20px;
    height: 20px;
    margin: 0 10px;
    cursor: pointer;
    vertical-align: sub;
  }
  span {
    &:first-child {
      color: #333333 !important;
      font-weight: bold;
    }

    &:last-child {
      color: #888888;
    }
  }
  a {
    outline: none;
  }
}

.total-container {
  position: absolute;
  left: 57%;
  top: 55%;
  transform: translate(-50%, -50%);
  span {
    font-size: 16px;
    color: #fd345d;
    font-weight: bold;
  }
}

.loadBtn {
  position: absolute;
  right: 20px;
  top: 0;
  height: 24px;
  width: 76px;
  background-color: rgb(0, 128, 255);
  border: none;
  color: #ffffff;
  font-size: 14px;
  border-radius: 5px;
  &:hover {
    opacity: 0.8;
  }
}

.tableBtn {
  z-index: 1;
}

.ant-table-wrapper {
  margin-top: 32px;
}

.ant-table-body {
  overflow: auto !important;
}

.ant-table.ant-table-bordered
  > .ant-table-container
  > .ant-table-body
  > table
  > tbody
  > tr
  > td,
.ant-table.ant-table-bordered
  > .ant-table-container
  > .ant-table-body
  > table
  > tfoot
  > tr
  > td,
.ant-table.ant-table-bordered
  > .ant-table-container
  > .ant-table-body
  > table
  > tfoot
  > tr
  > th,
.ant-table.ant-table-bordered
  > .ant-table-container
  > .ant-table-body
  > table
  > thead
  > tr
  > th,
.ant-table.ant-table-bordered
  > .ant-table-container
  > .ant-table-content
  > table
  > tbody
  > tr
  > td,
.ant-table.ant-table-bordered
  > .ant-table-container
  > .ant-table-content
  > table
  > tfoot
  > tr
  > td,
.ant-table.ant-table-bordered
  > .ant-table-container
  > .ant-table-content
  > table
  > tfoot
  > tr
  > th,
.ant-table.ant-table-bordered
  > .ant-table-container
  > .ant-table-content
  > table
  > thead
  > tr
  > th,
.ant-table.ant-table-bordered
  > .ant-table-container
  > .ant-table-header
  > table
  > tbody
  > tr
  > td,
.ant-table.ant-table-bordered
  > .ant-table-container
  > .ant-table-header
  > table
  > tfoot
  > tr
  > td,
.ant-table.ant-table-bordered
  > .ant-table-container
  > .ant-table-header
  > table
  > tfoot
  > tr
  > th,
.ant-table.ant-table-bordered
  > .ant-table-container
  > .ant-table-header
  > table
  > thead
  > tr
  > th {
  text-align: center !important;
}

.ant-table.ant-table-bordered
  > .ant-table-container
  > .ant-table-header
  > table
  > thead
  > tr
  > th {
  font-weight: bold;
}

StackBarChartPlugin.js:组件文件拼装导出,确定组件目录及描述信息

源码: 

import { t, ChartMetadata, ChartPlugin } from '@superset-ui/core';
import transformProps from './transformProps';
import thumbnail from './images/thumbnail.png';
import controlPanel from './controlPanel';
import buildQuery from './buildQuery';

const metadata = new ChartMetadata({
  category: t('Tools'),
  name: t('Stack bar chart'),
  description: t(`Stack bar chart`),
  thumbnail,
  useLegacyApi: false,
});

export default class StackBarChartPlugin extends ChartPlugin {
  constructor() {
    super({
      controlPanel,
      metadata,
      transformProps,
      loadChart: () => import('./StackBarChart'),
      buildQuery,
    });
  }
}

transformProps.ts:处理组件响应的数据格式供组件内部使用

源码:

import { StackBarChartProps } from './types';

export default function transformProps(chartProps: StackBarChartProps) {
  const { queriesData, formData, width, height } = chartProps;
  const {
    metrics = [],
    subheader = '',
    tips = '',
    funnelUnit = '',
    groupMertic = '',
    countMertic = '',
  } = formData;
  const { data } = queriesData[0];
  const randomId = Math.floor(Math.random() * 100);
  let unitName = funnelUnit;
  if (!unitName) {
    unitName = '个';
  }
  const chartId = `${metrics[0]}${randomId}`;
  const xData: any = [];
  const yData: any = [];
  const legendData: any = [];
  const timeValue: any =
    typeof metrics[0] === 'object' ? metrics[0].label : metrics[0];
  // 获取xdata和图例
  for (let index = 0; index < data.length; index += 1) {
    const element = data[index];
    if (!xData.includes(element[timeValue])) {
      xData.push(element[timeValue]);
    }
    if (!legendData.includes(element[groupMertic])) {
      legendData.push(element[groupMertic]);
    }
  }
  for (let index = 0; index < legendData.length; index += 1) {
    const group = legendData[index];
    const resData: any = [];
    for (let index = 0; index < xData.length; index += 1) {
      const date = xData[index];
      const filterValue = data.filter(
        (v: any) => v[timeValue] === date && v[groupMertic] === group,
      );
      const count = filterValue.length ? filterValue[0][countMertic] : 0;
      resData.push(count);
    }
    yData.push(resData);
  }

  return {
    chartId,
    xData,
    yData,
    legendData,
    unitName,
    width,
    height,
    subheader,
    tips,
  };
}

types.ts:定义组件内参数的类型

源码: 

import {
  NumberFormatter,
  TimeFormatter,
  TimeGranularity,
  QueryFormMetric,
  ChartProps,
  DataRecord,
  DataRecordValue,
  DataRecordFilters,
  GenericDataType,
  QueryMode,
  ChartDataResponseResult,
  QueryFormData,
  SetDataMaskHook,
} from '@superset-ui/core';
import { ColorFormatters, ColumnConfig } from '@superset-ui/chart-controls';

export type CustomFormatter = (value: DataRecordValue) => string;

export interface DataColumnMeta {
  // `key` is what is called `label` in the input props
  key: string;
  // `label` is verbose column name used for rendering
  label: string;
  dataType: GenericDataType;
  formatter?: TimeFormatter | NumberFormatter | CustomFormatter;
  isMetric?: boolean;
  isPercentMetric?: boolean;
  isNumeric?: boolean;
  config?: ColumnConfig;
}

export interface TableChartData {
  records: DataRecord[];
  columns: string[];
}

export type TableChartFormData = QueryFormData & {
  align_pn?: boolean;
  color_pn?: boolean;
  include_time?: boolean;
  include_search?: boolean;
  query_mode?: QueryMode;
  page_length?: string | number | null; // null means auto-paginate
  metrics?: QueryFormMetric[] | null;
  percent_metrics?: QueryFormMetric[] | null;
  timeseries_limit_metric?: QueryFormMetric[] | QueryFormMetric | null;
  groupby?: QueryFormMetric[] | null;
  all_columns?: QueryFormMetric[] | null;
  order_desc?: boolean;
  show_cell_bars?: boolean;
  table_timestamp_format?: string;
  emit_filter?: boolean;
  time_grain_sqla?: TimeGranularity;
  column_config?: Record<string, ColumnConfig>;
};

export interface StackBarChartProps extends ChartProps {
  ownCurrentState: {
    pageSize?: number;
    currentPage?: number;
  };
  rawFormData: TableChartFormData;
  queriesData: ChartDataResponseResult[];
}

export interface TableChartTransformedProps<D extends DataRecord = DataRecord> {
  timeGrain?: TimeGranularity;
  height: number;
  width: number;
  rowCount?: number;
  serverPagination: boolean;
  serverPaginationData: { pageSize?: number; currentPage?: number };
  setDataMask: SetDataMaskHook;
  isRawRecords?: boolean;
  data: D[];
  totals?: D;
  columns: DataColumnMeta[];
  metrics?: (keyof D)[];
  percentMetrics?: (keyof D)[];
  pageSize?: number;
  showCellBars?: boolean;
  sortDesc?: boolean;
  includeSearch?: boolean;
  alignPositiveNegative?: boolean;
  colorPositiveNegative?: boolean;
  tableTimestampFormat?: string;
  // These are dashboard filters, don't be confused with in-chart search filter
  // enabled by `includeSearch`
  filters?: DataRecordFilters;
  emitFilter?: boolean;
  onChangeFilter?: ChartProps['hooks']['onAddFilter'];
  columnColorFormatters?: ColorFormatters;
}

export default {};

二、预设组件信息

1、新组件添加到组件库中

配置文件:superset/superset-frontend/src/visualizations/presets/MainPreset.js

import StackBarDefineChartPlugin from '../StackBar/StackBarChartPlugin';
new StackBarDefineChartPlugin().configure({ key: 'stack_bar_chart' }),

2、把组件的key添加到组件类型控制文件中

配置文件:

superset/superset-frontend/src/explore/components/controls

/VizTypeControl/VizTypeGallery.tsx

const DEFAULT_ORDER = [
'stack_bar_chart',
];

3、配置组件的后端数据可视化渲染方式

配置文件:superset/superset/viz.py

class StackBarDefineChartViz(BaseViz):

    """A basic html table that is sortable and searchable"""

    viz_type = "stack_bar_chart"
    verbose_name = _("Stack Bar Chart")
    credits = 'a <a href="https://github.com/airbnb/superset">Superset</a> original'
    is_timeseries = False
    enforce_numerical_metrics = False

    def process_metrics(self) -> None:
        """Process form data and store parsed column configs.
        1. Determine query mode based on form_data params.
             - Use `query_mode` if it has a valid value
             - Set as RAW mode if `all_columns` is set
             - Otherwise defaults to AGG mode
        2. Determine output columns based on query mode.
        """
        # Verify form data first: if not specifying query mode, then cannot have both
        # GROUP BY and RAW COLUMNS.
        if (
            not self.form_data.get("query_mode")
            and self.form_data.get("all_columns")
            and (
                self.form_data.get("groupby")
                or self.form_data.get("metrics")
                or self.form_data.get("percent_metrics")
            )
        ):
            raise QueryObjectValidationError(
                _(
                    "You cannot use [Columns] in combination with "
                    "[Group By]/[Metrics]/[Percentage Metrics]. "
                    "Please choose one or the other."
                )
            )

        super().process_metrics()

        self.query_mode: QueryMode = QueryMode.get(
            self.form_data.get("query_mode")
        ) or (
            # infer query mode from the presence of other fields
            QueryMode.RAW
            if len(self.form_data.get("all_columns") or []) > 0
            else QueryMode.AGGREGATE
        )

        columns: List[str]  # output columns sans time and percent_metric column
        percent_columns: List[str] = []  # percent columns that needs extra computation

        if self.query_mode == QueryMode.RAW:
            columns = get_metric_names(self.form_data.get("all_columns"))
        else:
            columns = get_metric_names(
                self.form_data.get("metrics")
            )
            percent_columns = get_metric_names(
                self.form_data.get("percent_metrics") or []
            )
        self.columns = columns
        self.percent_columns = percent_columns
        self.is_timeseries = self.should_be_timeseries()

    def should_be_timeseries(self) -> bool:
        # TODO handle datasource-type-specific code in datasource
        conditions_met = (
            self.form_data.get("granularity")
            and self.form_data.get("granularity") != "all"
        ) or (
            self.form_data.get("granularity_sqla")
            and self.form_data.get("time_grain_sqla")
        )
        if self.form_data.get("include_time") and not conditions_met:
            raise QueryObjectValidationError(
                _("Pick a granularity in the Time section or " "uncheck 'Include Time'")
            )
        return bool(self.form_data.get("include_time"))

    def query_obj(self) -> QueryObjectDict:
        query_obj = super().query_obj()
        if self.query_mode == QueryMode.RAW:
            query_obj["columns"] = self.form_data.get("all_columns")
            order_by_cols = self.form_data.get("order_by_cols") or []
            query_obj["orderby"] = [json.loads(t) for t in order_by_cols]
            # must disable groupby and metrics in raw mode
            query_obj["groupby"] = []
            query_obj["metrics"] = []
            # raw mode does not support timeseries queries
            query_obj["timeseries_limit_metric"] = None
            query_obj["timeseries_limit"] = None
            query_obj["is_timeseries"] = None
        else:
            sort_by = self.form_data.get("timeseries_limit_metric")
            if sort_by:
                sort_by_label = utils.get_metric_name(sort_by)
                if sort_by_label not in utils.get_metric_names(query_obj["metrics"]):
                    query_obj["metrics"].append(sort_by)
                query_obj["orderby"] = [
                    (sort_by, not self.form_data.get("order_desc", True))
                ]
            elif query_obj["metrics"]:
                # Legacy behavior of sorting by first metric by default
                first_metric = query_obj["metrics"][0]
                query_obj["orderby"] = [
                    (first_metric, not self.form_data.get("order_desc", True))
                ]
        return query_obj

    def get_data(self, df: pd.DataFrame) -> VizData:
        """
        Transform the query result to the table representation.

        :param df: The interim dataframe
        :returns: The table visualization data

        The interim dataframe comprises of the group-by and non-group-by columns and
        the union of the metrics representing the non-percent and percent metrics. Note
        the percent metrics have yet to be transformed.
        """
        # Transform the data frame to adhere to the UI ordering of the columns and
        # metrics whilst simultaneously computing the percentages (via normalization)
        # for the percent metrics.
        if df.empty:
            return None

        columns, percent_columns = self.columns, self.percent_columns
        if DTTM_ALIAS in df and self.is_timeseries:
            columns = [DTTM_ALIAS] + columns
        df = pd.concat(
            [
                df[columns],
                (df[percent_columns].div(df[percent_columns].sum()).add_prefix("%")),
            ],
            axis=1,
        )
        return self.handle_js_int_overflow(
            dict(records=df.to_dict(orient="records"), columns=list(df.columns))
        )

    @staticmethod
    def json_dumps(query_obj: Any, sort_keys: bool = False) -> str:
        return json.dumps(
            query_obj,
            default=utils.json_iso_dttm_ser,
            sort_keys=sort_keys,
            ignore_nan=True,
        )

三、使用组件

1、点击图表tab下的新增组件按钮

2、选择数据集及图表类型创建新的图表

3、根据图表使用手册配置相关信息,点击RUN按钮执行数据并渲染图表

4、点击SAVE按钮保存图表或保存到指定的dashboard中

欢迎在评论区交流。

如果文章对你有所帮助,❤️关注+点赞❤️鼓励一下!博主会持续更新。。。。

往期回顾

 CSS多栏布局-两栏布局和三栏布局

 border边框影响布局解决方案

 css 设置字体渐变色和阴影

css 重置样式表(Normalize.css)

 css实现元素居中的6种方法 

Angular8升级至Angular13遇到的问题

前端vscode必备插件(强烈推荐)

Webpack性能优化

vite构建如何兼容低版本浏览器

前端性能优化9大策略(面试一网打尽)!

vue3.x使用prerender-spa-plugin预渲染达到SEO优化

 vite构建打包性能优化

 vue3.x使用prerender-spa-plugin预渲染达到SEO优化

 ES6实用的技巧和方法有哪些?

 css超出部分显示省略号

vue3使用i18n 实现国际化

vue3中使用prismjs或者highlight.js实现代码高亮

什么是 XSS 攻击?什么是 CSRF?什么是点击劫持?如何防御

;