feat(plugin-chart-echarts): add Gantt Chart plugin (#33716)

This commit is contained in:
Vladislav Korenkov
2025-07-04 07:23:50 +10:00
committed by GitHub
parent cb6342fc73
commit 9f0523977d
36 changed files with 1845 additions and 108 deletions

View File

@@ -0,0 +1,89 @@
/**
* 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 { useEffect, useRef, useState } from 'react';
import { sharedControlComponents } from '@superset-ui/chart-controls';
import { t } from '@superset-ui/core';
import Echart from '../components/Echart';
import { EchartsGanttChartTransformedProps } from './types';
import { EventHandlers } from '../types';
const { RadioButtonControl } = sharedControlComponents;
export default function EchartsGantt(props: EchartsGanttChartTransformedProps) {
const {
height,
width,
echartOptions,
selectedValues,
refs,
formData,
setControlValue,
onLegendStateChanged,
} = props;
const extraControlRef = useRef<HTMLDivElement>(null);
const [extraHeight, setExtraHeight] = useState(0);
useEffect(() => {
const updatedHeight = extraControlRef.current?.offsetHeight ?? 0;
setExtraHeight(updatedHeight);
}, [formData.showExtraControls]);
const eventHandlers: EventHandlers = {
legendselectchanged: payload => {
requestAnimationFrame(() => {
onLegendStateChanged?.(payload.selected);
});
},
legendselectall: payload => {
requestAnimationFrame(() => {
onLegendStateChanged?.(payload.selected);
});
},
legendinverseselect: payload => {
requestAnimationFrame(() => {
onLegendStateChanged?.(payload.selected);
});
},
};
return (
<>
<div ref={extraControlRef} css={{ textAlign: 'center' }}>
{formData.showExtraControls ? (
<RadioButtonControl
options={[
[false, t('Plain')],
[true, t('Subcategories')],
]}
value={formData.subcategories}
onChange={v => setControlValue?.('subcategories', v)}
/>
) : null}
</div>
<Echart
refs={refs}
height={height - extraHeight}
width={width}
echartOptions={echartOptions}
selectedValues={selectedValues}
eventHandlers={eventHandlers}
/>
</>
);
}

View File

@@ -0,0 +1,61 @@
/**
* 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 {
QueryFormData,
QueryObject,
buildQueryContext,
ensureIsArray,
} from '@superset-ui/core';
export default function buildQuery(formData: QueryFormData) {
const {
start_time,
end_time,
y_axis,
series,
tooltip_columns,
tooltip_metrics,
order_by_cols,
} = formData;
const groupBy = ensureIsArray(series);
const orderby = ensureIsArray(order_by_cols).map(
expr => JSON.parse(expr) as [string, boolean],
);
const columns = Array.from(
new Set([
start_time,
end_time,
y_axis,
...groupBy,
...ensureIsArray(tooltip_columns),
...orderby.map(v => v[0]),
]),
);
return buildQueryContext(formData, (baseQueryObject: QueryObject) => [
{
...baseQueryObject,
columns,
metrics: ensureIsArray(tooltip_metrics),
orderby,
series_columns: groupBy,
},
]);
}

View File

@@ -0,0 +1,26 @@
/**
* 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.
*/
export const ELEMENT_HEIGHT_SCALE = 0.85 as const;
export enum Dimension {
StartTime = 'startTime',
EndTime = 'endTime',
Index = 'index',
SeriesCount = 'seriesCount',
}

View File

@@ -0,0 +1,136 @@
/**
* 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 {
ControlPanelConfig,
ControlSubSectionHeader,
sections,
sharedControls,
} from '@superset-ui/chart-controls';
import { GenericDataType, t } from '@superset-ui/core';
import {
legendSection,
showExtraControls,
tooltipTimeFormatControl,
tooltipValuesFormatControl,
} from '../controls';
const config: ControlPanelConfig = {
controlPanelSections: [
{
label: t('Query'),
expanded: true,
controlSetRows: [
[
{
name: 'start_time',
config: {
...sharedControls.entity,
label: t('Start Time'),
description: undefined,
allowedDataTypes: [GenericDataType.Temporal],
},
},
],
[
{
name: 'end_time',
config: {
...sharedControls.entity,
label: t('End Time'),
description: undefined,
allowedDataTypes: [GenericDataType.Temporal],
},
},
],
[
{
name: 'y_axis',
config: {
...sharedControls.x_axis,
label: t('Y-axis'),
description: t('Dimension to use on y-axis.'),
initialValue: () => undefined,
},
},
],
['series'],
[
{
name: 'subcategories',
config: {
type: 'CheckboxControl',
label: t('Subcategories'),
description: t(
'Divides each category into subcategories based on the values in ' +
'the dimension. It can be used to exclude intersections.',
),
renderTrigger: true,
default: false,
visibility: ({ controls }) => !!controls?.series?.value,
},
},
],
['tooltip_metrics'],
['tooltip_columns'],
['adhoc_filters'],
['order_by_cols'],
['row_limit'],
],
},
{
...sections.titleControls,
controlSetRows: [...sections.titleControls.controlSetRows.slice(0, -1)],
},
{
label: t('Chart Options'),
expanded: true,
tabOverride: 'customize',
controlSetRows: [
['color_scheme'],
...legendSection,
['zoomable'],
[showExtraControls],
[<ControlSubSectionHeader>{t('X Axis')}</ControlSubSectionHeader>],
[
{
name: 'x_axis_time_bounds',
config: {
type: 'TimeRangeControl',
label: t('Bounds'),
description: t(
'Bounds for the X-axis. Selected time merges with ' +
'min/max date of the data. When left empty, bounds ' +
'dynamically defined based on the min/max of the data.',
),
renderTrigger: true,
allowClear: true,
allowEmpty: [true, true],
},
},
],
['x_axis_time_format'],
[<ControlSubSectionHeader>{t('Tooltip')}</ControlSubSectionHeader>],
[tooltipTimeFormatControl],
[tooltipValuesFormatControl],
],
},
],
};
export default config;

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

View File

@@ -0,0 +1,54 @@
/**
* 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 { Behavior, t } from '@superset-ui/core';
import transformProps from './transformProps';
import controlPanel from './controlPanel';
import buildQuery from './buildQuery';
import { EchartsChartPlugin } from '../types';
import thumbnail from './images/thumbnail.png';
import example1 from './images/example1.png';
import example2 from './images/example2.png';
export default class EchartsGanttChartPlugin extends EchartsChartPlugin {
constructor() {
super({
buildQuery,
controlPanel,
loadChart: () => import('./EchartsGantt'),
metadata: {
behaviors: [
Behavior.InteractiveChart,
Behavior.DrillToDetail,
Behavior.DrillBy,
],
credits: ['https://echarts.apache.org'],
name: t('Gantt Chart'),
description: t(
'Gantt chart visualizes important events over a time span. ' +
'Every data point displayed as a separate event along a ' +
'horizontal line.',
),
tags: [t('ECharts'), t('Featured'), t('Timeline'), t('Time')],
thumbnail,
exampleGallery: [{ url: example1 }, { url: example2 }],
},
transformProps,
});
}
}

View File

@@ -0,0 +1,440 @@
/**
* 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 {
CustomSeriesOption,
CustomSeriesRenderItem,
EChartsCoreOption,
LineSeriesOption,
} from 'echarts';
import {
AxisType,
CategoricalColorNamespace,
DataRecord,
DataRecordValue,
GenericDataType,
getColumnLabel,
getNumberFormatter,
t,
tooltipHtml,
} from '@superset-ui/core';
import { CallbackDataParams } from 'echarts/types/src/util/types';
import dayjs from 'dayjs';
import {
Cartesian2dCoordSys,
EchartsGanttChartProps,
EchartsGanttFormData,
} from './types';
import { DEFAULT_FORM_DATA, TIMESERIES_CONSTANTS } from '../constants';
import { Refs } from '../types';
import { getLegendProps, groupData } from '../utils/series';
import {
getTooltipTimeFormatter,
getXAxisFormatter,
} from '../utils/formatters';
import { defaultGrid } from '../defaults';
import { getPadding } from '../Timeseries/transformers';
import { convertInteger } from '../utils/convertInteger';
import { getTooltipLabels } from '../utils/tooltip';
import { Dimension, ELEMENT_HEIGHT_SCALE } from './constants';
const renderItem: CustomSeriesRenderItem = (params, api) => {
const startX = api.value(Dimension.StartTime);
const endX = api.value(Dimension.EndTime);
const index = Number(api.value(Dimension.Index));
const seriesCount = Number(api.value(Dimension.SeriesCount));
if (Number.isNaN(index)) {
return null;
}
const startY = seriesCount - 1 - index;
const endY = startY - 1;
const startCoord = api.coord([startX, startY]);
const endCoord = api.coord([endX, endY]);
const baseHeight = endCoord[1] - startCoord[1];
const height = baseHeight * ELEMENT_HEIGHT_SCALE;
const coordSys = params.coordSys as Cartesian2dCoordSys;
const bounds = [coordSys.x, coordSys.x + coordSys.width];
// left bound
startCoord[0] = Math.max(startCoord[0], bounds[0]);
endCoord[0] = Math.max(startCoord[0], endCoord[0]);
// right bound
startCoord[0] = Math.min(startCoord[0], bounds[1]);
endCoord[0] = Math.min(endCoord[0], bounds[1]);
const width = endCoord[0] - startCoord[0];
if (width <= 0 || height <= 0) {
return null;
}
return {
type: 'rect',
transition: ['shape'],
shape: {
x: startCoord[0],
y: startCoord[1] - height - (baseHeight - height) / 2,
width,
height,
},
style: api.style(),
};
};
export default function transformProps(chartProps: EchartsGanttChartProps) {
const {
formData,
queriesData,
height,
hooks,
filterState,
width,
theme,
emitCrossFilters,
datasource,
legendState,
} = chartProps;
const {
startTime,
endTime,
yAxis,
series: dimension,
tooltipMetrics,
tooltipColumns,
xAxisTimeFormat,
tooltipTimeFormat,
tooltipValuesFormat,
colorScheme,
sliceId,
zoomable,
legendMargin,
legendOrientation,
legendType,
showLegend,
yAxisTitle,
yAxisTitleMargin,
xAxisTitle,
xAxisTitleMargin,
xAxisTimeBounds,
subcategories,
}: EchartsGanttFormData = {
...DEFAULT_FORM_DATA,
...formData,
};
const { setControlValue, onLegendStateChanged } = hooks;
const { data = [], colnames = [], coltypes = [] } = queriesData[0];
const refs: Refs = {};
const startTimeLabel = getColumnLabel(startTime);
const endTimeLabel = getColumnLabel(endTime);
const yAxisLabel = getColumnLabel(yAxis);
const dimensionLabel = dimension ? getColumnLabel(dimension) : undefined;
const tooltipLabels = getTooltipLabels({ tooltipMetrics, tooltipColumns });
const seriesMap = groupData(data, dimensionLabel);
const seriesInCategoriesMap = new Map<
DataRecordValue | undefined,
Map<DataRecordValue | undefined, number>
>();
data.forEach(datum => {
const category = datum[yAxisLabel];
let dimensionValue: DataRecordValue | undefined;
if (dimensionLabel) {
if (legendState && !legendState[String(datum[dimensionLabel])]) {
return;
}
if (subcategories) {
dimensionValue = datum[dimensionLabel];
}
}
const seriesMap = seriesInCategoriesMap.get(category);
if (seriesMap) {
const dimensionMapValue = seriesMap.get(dimensionValue);
if (dimensionMapValue === undefined) {
seriesMap.set(dimensionValue, seriesMap.size);
}
} else {
seriesInCategoriesMap.set(category, new Map([[dimensionValue, 0]]));
}
});
let seriesCount = 0;
const categoryAndSeriesToIndexMap: typeof seriesInCategoriesMap = new Map();
Array.from(seriesInCategoriesMap.entries()).forEach(([key, map]) => {
categoryAndSeriesToIndexMap.set(
key,
new Map(
Array.from(map.entries()).map(([key2, idx]) => [
key2,
seriesCount + idx,
]),
),
);
seriesCount += map.size;
});
const borderLines: { yAxis: number }[] = [];
const categoryLines: { yAxis: number; name?: string }[] = [];
let sum = 0;
let prevSum = 0;
Array.from(seriesInCategoriesMap.entries()).forEach(([key, map]) => {
sum += map.size;
categoryLines.push({
yAxis: seriesCount - (sum + prevSum) / 2,
name: key ? String(key) : undefined,
});
borderLines.push({ yAxis: seriesCount - sum });
prevSum = sum;
});
const xAxisFormatter = getXAxisFormatter(xAxisTimeFormat);
const tooltipTimeFormatter = getTooltipTimeFormatter(tooltipTimeFormat);
const tooltipValuesFormatter = getNumberFormatter(tooltipValuesFormat);
const bounds: [number | undefined, number | undefined] = [
undefined,
undefined,
];
if (xAxisTimeBounds?.[0]) {
const minDate = Math.min(
...data.map(datum => Number(datum[startTimeLabel] ?? 0)),
);
const time = dayjs(xAxisTimeBounds[0], 'HH:mm:ss');
bounds[0] = +dayjs
.utc(minDate)
.hour(time.hour())
.minute(time.minute())
.second(time.second());
}
if (xAxisTimeBounds?.[1]) {
const maxDate = Math.min(
...data.map(datum => Number(datum[endTimeLabel] ?? 0)),
);
const time = dayjs(xAxisTimeBounds[1], 'HH:mm:ss');
bounds[1] = +dayjs
.utc(maxDate)
.hour(time.hour())
.minute(time.minute())
.second(time.second());
}
const padding = getPadding(
showLegend && seriesMap.size > 1,
legendOrientation,
false,
zoomable,
legendMargin,
!!xAxisTitle,
'Left',
convertInteger(yAxisTitleMargin),
convertInteger(xAxisTitleMargin),
);
const colorScale = CategoricalColorNamespace.getScale(colorScheme as string);
const getIndex = (datum: DataRecord) => {
const seriesMap = categoryAndSeriesToIndexMap.get(datum[yAxisLabel]);
const series =
subcategories && dimensionLabel ? datum[dimensionLabel] : undefined;
return seriesMap ? seriesMap.get(series) : undefined;
};
const series: (CustomSeriesOption | LineSeriesOption)[] = Array.from(
seriesMap.entries(),
)
.map(([key, data], idx) => ({
name: key as string | undefined,
// For some reason items can visually disappear if progressive enabled.
progressive: 0,
itemStyle: {
color: colorScale(String(key), sliceId ?? idx),
},
type: 'custom' as const,
renderItem,
data: data.map(datum => ({
value: [
datum[startTimeLabel],
datum[endTimeLabel],
getIndex(datum),
seriesCount,
...Object.values(datum),
],
})),
dimensions: [...Object.values(Dimension), ...colnames],
encode: {
x: [0, 1],
},
}))
.sort((a, b) => String(a.name).localeCompare(String(b.name)));
series.push(
{
animation: false,
type: 'line' as const,
markLine: {
silent: true,
symbol: ['none', 'none'],
lineStyle: {
type: 'dashed',
// eslint-disable-next-line theme-colors/no-literal-colors
color: '#dbe0ea',
},
label: {
show: false,
},
data: borderLines,
},
},
{
animation: false,
type: 'line',
markLine: {
silent: true,
symbol: ['none', 'none'],
lineStyle: {
type: 'solid',
// eslint-disable-next-line theme-colors/no-literal-colors
color: '#00000000',
},
label: {
show: true,
position: 'start',
formatter: '{b}',
},
data: categoryLines,
},
},
);
const tooltipFormatterMap = {
[GenericDataType.Numeric]: tooltipValuesFormatter,
[GenericDataType.String]: undefined,
[GenericDataType.Temporal]: tooltipTimeFormatter,
[GenericDataType.Boolean]: undefined,
};
const echartOptions: EChartsCoreOption = {
useUTC: true,
tooltip: {
formatter: (params: CallbackDataParams) =>
tooltipHtml(
tooltipLabels.map(label => {
const offset = Object.keys(Dimension).length;
const dimensionNames = params.dimensionNames!.slice(offset);
const data = (params.value as any[]).slice(offset);
const idx = dimensionNames.findIndex(v => v === label)!;
const value = data[idx];
const type = coltypes[idx];
return [label, tooltipFormatterMap[type]?.(value) ?? value];
}),
dimensionLabel ? params.seriesName : undefined,
),
},
legend: {
...getLegendProps(
legendType,
legendOrientation,
showLegend,
theme,
zoomable,
legendState,
),
},
grid: {
...defaultGrid,
...padding,
},
dataZoom: zoomable && [
{
type: 'slider',
filterMode: 'none',
start: TIMESERIES_CONSTANTS.dataZoomStart,
end: TIMESERIES_CONSTANTS.dataZoomEnd,
bottom: TIMESERIES_CONSTANTS.zoomBottom,
},
],
toolbox: {
show: zoomable,
top: TIMESERIES_CONSTANTS.toolboxTop,
right: TIMESERIES_CONSTANTS.toolboxRight,
feature: {
dataZoom: {
yAxisIndex: false,
title: {
zoom: t('zoom area'),
back: t('restore zoom'),
},
},
},
},
series,
xAxis: {
name: xAxisTitle,
nameLocation: 'middle',
type: AxisType.Time,
nameGap: convertInteger(xAxisTitleMargin),
axisLabel: {
formatter: xAxisFormatter,
hideOverlap: true,
},
min: bounds[0],
max: bounds[1],
},
yAxis: {
name: yAxisTitle,
nameGap: convertInteger(yAxisTitleMargin),
nameLocation: 'middle',
axisLabel: {
show: false,
},
splitLine: {
show: false,
},
type: AxisType.Value,
min: 0,
max: seriesCount,
},
};
return {
formData,
queriesData,
echartOptions,
height,
filterState,
width,
theme,
hooks,
emitCrossFilters,
datasource,
refs,
setControlValue,
onLegendStateChanged,
};
}

View File

@@ -0,0 +1,71 @@
/**
* 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 {
ChartDataResponseResult,
ChartProps,
QueryFormColumn,
QueryFormData,
QueryFormMetric,
} from '@superset-ui/core';
import {
BaseTransformedProps,
CrossFilterTransformedProps,
LegendFormData,
} from '../types';
export type EchartsGanttChartTransformedProps =
BaseTransformedProps<EchartsGanttFormData> & CrossFilterTransformedProps;
export type EchartsGanttFormData = QueryFormData &
LegendFormData & {
viz_type: 'gantt_chart';
startTime: QueryFormColumn;
endTime: QueryFormColumn;
yAxis: QueryFormColumn;
tooltipMetrics: QueryFormMetric[];
tooltipColumns: QueryFormColumn[];
series?: QueryFormColumn;
xAxisTimeFormat?: string;
tooltipTimeFormat?: string;
tooltipValuesFormat?: string;
colorScheme?: string;
zoomable?: boolean;
xAxisTitle?: string;
xAxisTitleMargin?: number;
yAxisTitle?: string;
yAxisTitleMargin?: number;
yAxisTitlePosition?: string;
xAxisTimeBounds?: [string | null, string | null];
subcategories?: boolean;
showExtraControls?: boolean;
};
export interface EchartsGanttChartProps
extends ChartProps<EchartsGanttFormData> {
formData: EchartsGanttFormData;
queriesData: ChartDataResponseResult[];
}
export interface Cartesian2dCoordSys {
type: 'cartesian2d';
x: number;
y: number;
width: number;
height: number;
}

View File

@@ -58,7 +58,6 @@ const {
stack,
truncateYAxis,
yAxisBounds,
zoomable,
yAxisIndex,
} = DEFAULT_FORM_DATA;
@@ -352,18 +351,7 @@ const config: ControlPanelConfig = {
['time_shift_color'],
...createCustomizeSection(t('Query A'), ''),
...createCustomizeSection(t('Query B'), 'B'),
[
{
name: 'zoomable',
config: {
type: 'CheckboxControl',
label: t('Data Zoom'),
default: zoomable,
renderTrigger: true,
description: t('Enable data zooming controls'),
},
},
],
['zoomable'],
[minorTicks],
...legendSection,
[<ControlSubSectionHeader>{t('X Axis')}</ControlSubSectionHeader>],

View File

@@ -54,7 +54,6 @@ const {
seriesType,
truncateYAxis,
yAxisBounds,
zoomable,
} = DEFAULT_FORM_DATA;
const config: ControlPanelConfig = {
controlPanelSections: [
@@ -171,18 +170,7 @@ const config: ControlPanelConfig = {
},
],
[minorTicks],
[
{
name: 'zoomable',
config: {
type: 'CheckboxControl',
label: t('Data Zoom'),
default: zoomable,
renderTrigger: true,
description: t('Enable data zooming controls'),
},
},
],
['zoomable'],
...legendSection,
[<ControlSubSectionHeader>{t('X Axis')}</ControlSubSectionHeader>],
[

View File

@@ -48,14 +48,8 @@ import {
} from '../../constants';
import { StackControlsValue } from '../../../constants';
const {
logAxis,
minorSplitLine,
truncateYAxis,
yAxisBounds,
zoomable,
orientation,
} = DEFAULT_FORM_DATA;
const { logAxis, minorSplitLine, truncateYAxis, yAxisBounds, orientation } =
DEFAULT_FORM_DATA;
function createAxisTitleControl(axis: 'x' | 'y'): ControlSetRow[] {
const isXAxis = axis === 'x';
@@ -363,18 +357,7 @@ const config: ControlPanelConfig = {
},
],
[minorTicks],
[
{
name: 'zoomable',
config: {
type: 'CheckboxControl',
label: t('Data Zoom'),
default: zoomable,
renderTrigger: true,
description: t('Enable data zooming controls'),
},
},
],
['zoomable'],
...legendSection,
[<ControlSubSectionHeader>{t('X Axis')}</ControlSubSectionHeader>],
...createAxisControl('x'),

View File

@@ -55,7 +55,6 @@ const {
seriesType,
truncateYAxis,
yAxisBounds,
zoomable,
} = DEFAULT_FORM_DATA;
const config: ControlPanelConfig = {
controlPanelSections: [
@@ -158,18 +157,7 @@ const config: ControlPanelConfig = {
},
},
],
[
{
name: 'zoomable',
config: {
type: 'CheckboxControl',
label: t('Data Zoom'),
default: zoomable,
renderTrigger: true,
description: t('Enable data zooming controls'),
},
},
],
['zoomable'],
[minorTicks],
...legendSection,
[<ControlSubSectionHeader>{t('X Axis')}</ControlSubSectionHeader>],

View File

@@ -51,7 +51,6 @@ const {
rowLimit,
truncateYAxis,
yAxisBounds,
zoomable,
} = DEFAULT_FORM_DATA;
const config: ControlPanelConfig = {
controlPanelSections: [
@@ -100,18 +99,7 @@ const config: ControlPanelConfig = {
},
},
],
[
{
name: 'zoomable',
config: {
type: 'CheckboxControl',
label: t('Data Zoom'),
default: zoomable,
renderTrigger: true,
description: t('Enable data zooming controls'),
},
},
],
['zoomable'],
[minorTicks],
...legendSection,
[<ControlSubSectionHeader>{t('X Axis')}</ControlSubSectionHeader>],

View File

@@ -51,7 +51,6 @@ const {
rowLimit,
truncateYAxis,
yAxisBounds,
zoomable,
} = DEFAULT_FORM_DATA;
const config: ControlPanelConfig = {
controlPanelSections: [
@@ -100,18 +99,7 @@ const config: ControlPanelConfig = {
},
},
],
[
{
name: 'zoomable',
config: {
type: 'CheckboxControl',
label: t('Data Zoom'),
default: zoomable,
renderTrigger: true,
description: t('Enable data zooming controls'),
},
},
],
['zoomable'],
[minorTicks],
...legendSection,
[<ControlSubSectionHeader>{t('X Axis')}</ControlSubSectionHeader>],

View File

@@ -51,7 +51,6 @@ const {
rowLimit,
truncateYAxis,
yAxisBounds,
zoomable,
} = DEFAULT_FORM_DATA;
const config: ControlPanelConfig = {
controlPanelSections: [
@@ -152,18 +151,7 @@ const config: ControlPanelConfig = {
},
},
],
[
{
name: 'zoomable',
config: {
type: 'CheckboxControl',
label: t('Data Zoom'),
default: zoomable,
renderTrigger: true,
description: t('Enable data zooming controls'),
},
},
],
['zoomable'],
[minorTicks],
...legendSection,
[<ControlSubSectionHeader>{t('X Axis')}</ControlSubSectionHeader>],

View File

@@ -48,10 +48,12 @@ import {
TreemapChart,
HeatmapChart,
SunburstChart,
CustomChart,
} from 'echarts/charts';
import { CanvasRenderer } from 'echarts/renderers';
import {
TooltipComponent,
TitleComponent,
GridComponent,
VisualMapComponent,
LegendComponent,
@@ -83,6 +85,7 @@ use([
CanvasRenderer,
BarChart,
BoxplotChart,
CustomChart,
FunnelChart,
GaugeChart,
GraphChart,
@@ -104,6 +107,7 @@ use([
LegendComponent,
ToolboxComponent,
TooltipComponent,
TitleComponent,
VisualMapComponent,
LabelLayout,
]);

View File

@@ -22,6 +22,7 @@ import {
ControlSetItem,
ControlSetRow,
ControlSubSectionHeader,
CustomControlItem,
DEFAULT_SORT_SERIES_DATA,
SORT_SERIES_CHOICES,
sharedControls,
@@ -185,7 +186,7 @@ const richTooltipControl: ControlSetItem = {
},
};
const tooltipTimeFormatControl: ControlSetItem = {
export const tooltipTimeFormatControl: ControlSetItem = {
name: 'tooltipTimeFormat',
config: {
...sharedControls.x_axis_time_format,
@@ -195,6 +196,15 @@ const tooltipTimeFormatControl: ControlSetItem = {
},
};
export const tooltipValuesFormatControl: CustomControlItem = {
name: 'tooltipValuesFormat',
config: {
...sharedControls.y_axis_format,
label: t('Number format'),
clearable: false,
},
};
const tooltipSortByMetricControl: ControlSetItem = {
name: 'tooltipSortByMetric',
config: {
@@ -367,3 +377,13 @@ export const forceCategorical: ControlSetItem = {
description: t('Make the x-axis categorical'),
},
};
export const showExtraControls: CustomControlItem = {
name: 'show_extra_controls',
config: {
type: 'CheckboxControl',
label: t('Extra Controls'),
renderTrigger: true,
default: false,
},
};

View File

@@ -43,6 +43,7 @@ export { default as EchartsSunburstChartPlugin } from './Sunburst';
export { default as EchartsBubbleChartPlugin } from './Bubble';
export { default as EchartsSankeyChartPlugin } from './Sankey';
export { default as EchartsWaterfallChartPlugin } from './Waterfall';
export { default as EchartsGanttChartPlugin } from './Gantt';
export { default as BoxPlotTransformProps } from './BoxPlot/transformProps';
export { default as FunnelTransformProps } from './Funnel/transformProps';
@@ -60,6 +61,7 @@ export { default as BubbleTransformProps } from './Bubble/transformProps';
export { default as WaterfallTransformProps } from './Waterfall/transformProps';
export { default as HistogramTransformProps } from './Histogram/transformProps';
export { default as SankeyTransformProps } from './Sankey/transformProps';
export { default as GanttTransformProps } from './Gantt/transformProps';
export { DEFAULT_FORM_DATA as TimeseriesDefaultFormData } from './Timeseries/constants';

View File

@@ -685,3 +685,20 @@ export function extractTooltipKeys(
}
return [forecastValue[0][TOOLTIP_SERIES_KEY]];
}
export function groupData(data: DataRecord[], by?: string | null) {
const seriesMap: Map<DataRecordValue | undefined, DataRecord[]> = new Map();
if (by) {
data.forEach(datum => {
const value = seriesMap.get(datum[by]);
if (value) {
value.push(datum);
} else {
seriesMap.set(datum[by], [datum]);
}
});
} else {
seriesMap.set(undefined, data);
}
return seriesMap;
}

View File

@@ -18,6 +18,12 @@
*/
import type { CallbackDataParams } from 'echarts/types/src/util/types';
import {
QueryFormColumn,
QueryFormMetric,
getColumnLabel,
getMetricLabel,
} from '@superset-ui/core';
import { TOOLTIP_OVERFLOW_MARGIN, TOOLTIP_POINTER_MARGIN } from '../constants';
import { Refs } from '../types';
@@ -80,3 +86,16 @@ export function getDefaultTooltip(refs: Refs) {
},
};
}
export function getTooltipLabels({
tooltipMetrics,
tooltipColumns,
}: {
tooltipMetrics?: QueryFormMetric[];
tooltipColumns?: QueryFormColumn[];
}) {
return [
...(tooltipMetrics ?? []).map(v => getMetricLabel(v)),
...(tooltipColumns ?? []).map(v => getColumnLabel(v)),
];
}

View File

@@ -0,0 +1,64 @@
/**
* 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 { QueryFormData } from '@superset-ui/core';
import buildQuery from '../../src/Gantt/buildQuery';
describe('Gantt buildQuery', () => {
const formData: QueryFormData = {
datasource: '1__table',
viz_type: 'gantt_chart',
start_time: 'start_time',
end_time: 'end_time',
y_axis: {
label: 'Y Axis',
sqlExpression: 'SELECT 1',
expressionType: 'SQL',
},
series: 'series',
tooltip_metrics: ['tooltip_metric'],
tooltip_columns: ['tooltip_column'],
order_by_cols: [
JSON.stringify(['start_time', true]),
JSON.stringify(['order_col', false]),
],
};
it('should build query', () => {
const queryContext = buildQuery(formData);
const [query] = queryContext.queries;
expect(query.metrics).toStrictEqual(['tooltip_metric']);
expect(query.columns).toStrictEqual([
'start_time',
'end_time',
{
label: 'Y Axis',
sqlExpression: 'SELECT 1',
expressionType: 'SQL',
},
'series',
'tooltip_column',
'order_col',
]);
expect(query.series_columns).toStrictEqual(['series']);
expect(query.orderby).toStrictEqual([
['start_time', true],
['order_col', false],
]);
});
});

View File

@@ -0,0 +1,269 @@
/**
* 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 { AxisType, ChartProps, supersetTheme } from '@superset-ui/core';
import {
LegendOrientation,
LegendType,
} from '@superset-ui/plugin-chart-echarts';
import transformProps from '../../src/Gantt/transformProps';
import {
EchartsGanttChartProps,
EchartsGanttFormData,
} from '../../src/Gantt/types';
describe('Gantt transformProps', () => {
const formData: EchartsGanttFormData = {
viz_type: 'gantt_chart',
datasource: '1__table',
startTime: 'startTime',
endTime: 'endTime',
yAxis: {
label: 'Y Axis',
sqlExpression: 'y_axis',
expressionType: 'SQL',
},
tooltipMetrics: ['tooltip_metric'],
tooltipColumns: ['tooltip_column'],
series: 'series',
xAxisTimeFormat: '%H:%M',
tooltipTimeFormat: '%H:%M',
tooltipValuesFormat: 'DURATION_SEC',
colorScheme: 'bnbColors',
zoomable: true,
xAxisTitleMargin: undefined,
yAxisTitleMargin: undefined,
xAxisTimeBounds: [null, '19:00:00'],
subcategories: true,
legendMargin: 0,
legendOrientation: LegendOrientation.Top,
legendType: LegendType.Scroll,
showLegend: true,
sortSeriesAscending: true,
};
const queriesData = [
{
data: [
{
startTime: Date.UTC(2025, 1, 1, 13, 0, 0),
endTime: Date.UTC(2025, 1, 1, 14, 0, 0),
'Y Axis': 'first',
tooltip_column: 'tooltip value 1',
series: 'series value 1',
},
{
startTime: Date.UTC(2025, 1, 1, 18, 0, 0),
endTime: Date.UTC(2025, 1, 1, 20, 0, 0),
'Y Axis': 'second',
tooltip_column: 'tooltip value 2',
series: 'series value 2',
},
],
colnames: ['startTime', 'endTime', 'Y Axis', 'tooltip_column', 'series'],
},
];
const chartPropsConfig = {
formData,
queriesData,
theme: supersetTheme,
};
it('should transform chart props', () => {
const chartProps = new ChartProps(chartPropsConfig);
const transformedProps = transformProps(
chartProps as EchartsGanttChartProps,
);
expect(transformedProps.echartOptions.series).toHaveLength(4);
const series = transformedProps.echartOptions.series as any[];
const series0 = series[0];
const series1 = series[1];
// exclude renderItem because it can't be serialized
expect(typeof series0.renderItem).toBe('function');
delete series0.renderItem;
expect(typeof series1.renderItem).toBe('function');
delete series1.renderItem;
delete transformedProps.echartOptions.series;
expect(transformedProps).toEqual(
expect.objectContaining({
echartOptions: expect.objectContaining({
useUTC: true,
xAxis: {
name: '',
nameGap: 0,
nameLocation: 'middle',
max: Date.UTC(2025, 1, 1, 19, 0, 0),
min: undefined,
type: AxisType.Time,
axisLabel: {
hideOverlap: true,
formatter: expect.anything(),
},
},
yAxis: {
name: '',
nameGap: 0,
nameLocation: 'middle',
type: AxisType.Value,
// always 0
min: 0,
// equals unique categories count
max: 2,
axisLabel: {
show: false,
},
splitLine: {
show: false,
},
},
legend: expect.objectContaining({
show: true,
type: 'scroll',
selector: ['all', 'inverse'],
}),
tooltip: {
formatter: expect.anything(),
},
dataZoom: [
expect.objectContaining({
type: 'slider',
filterMode: 'none',
}),
],
}),
}),
);
expect(series0).toEqual({
name: 'series value 1',
type: 'custom',
progressive: 0,
itemStyle: {
color: expect.anything(),
},
data: [
{
value: [
Date.UTC(2025, 1, 1, 13, 0, 0),
Date.UTC(2025, 1, 1, 14, 0, 0),
0,
2,
Date.UTC(2025, 1, 1, 13, 0, 0),
Date.UTC(2025, 1, 1, 14, 0, 0),
'first',
'tooltip value 1',
'series value 1',
],
},
],
dimensions: [
'startTime',
'endTime',
'index',
'seriesCount',
'startTime',
'endTime',
'Y Axis',
'tooltip_column',
'series',
],
encode: {
x: [0, 1],
},
});
expect(series1).toEqual({
name: 'series value 2',
type: 'custom',
progressive: 0,
itemStyle: {
color: expect.anything(),
},
data: [
{
value: [
Date.UTC(2025, 1, 1, 18, 0, 0),
Date.UTC(2025, 1, 1, 20, 0, 0),
1,
2,
Date.UTC(2025, 1, 1, 18, 0, 0),
Date.UTC(2025, 1, 1, 20, 0, 0),
'second',
'tooltip value 2',
'series value 2',
],
},
],
dimensions: [
'startTime',
'endTime',
'index',
'seriesCount',
'startTime',
'endTime',
'Y Axis',
'tooltip_column',
'series',
],
encode: {
x: [0, 1],
},
});
expect(series[2]).toEqual({
// just for markLines
type: 'line',
animation: false,
markLine: {
data: [{ yAxis: 1 }, { yAxis: 0 }],
label: {
show: false,
},
silent: true,
symbol: ['none', 'none'],
lineStyle: {
type: 'dashed',
color: '#dbe0ea',
},
},
});
expect(series[3]).toEqual({
type: 'line',
animation: false,
markLine: {
data: [
{ yAxis: 1.5, name: 'first' },
{ yAxis: 0.5, name: 'second' },
],
label: {
show: true,
position: 'start',
formatter: '{b}',
},
lineStyle: expect.objectContaining({
color: '#00000000',
type: 'solid',
}),
silent: true,
symbol: ['none', 'none'],
},
});
});
});