目录
前言
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中
欢迎在评论区交流。
如果文章对你有所帮助,❤️关注+点赞❤️鼓励一下!博主会持续更新。。。。
往期回顾
vue3.x使用prerender-spa-plugin预渲染达到SEO优化
vue3.x使用prerender-spa-plugin预渲染达到SEO优化