Skip to content

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

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 14 commits into from
Jul 3, 2025
Merged
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
feat(viz): add Timeline Chart
Quatters committed Jun 7, 2025
commit 64ab9ffa9c7116ae3ea743f55a233007c62c79fe
Original file line number Diff line number Diff line change
@@ -29,6 +29,35 @@ export const TITLE_POSITION_OPTIONS: [string, string][] = [
['Left', t('Left')],
['Top', t('Top')],
];

export const xAxisTitleMarginControl = {
name: 'x_axis_title_margin',
config: {
type: 'SelectControl',
freeForm: true,
clearable: true,
label: t('X Axis Title Margin'),
renderTrigger: true,
default: TITLE_MARGIN_OPTIONS[0],
choices: formatSelectOptions(TITLE_MARGIN_OPTIONS),
description: t('Changing this control takes effect instantly'),
},
};

export const yAxisTitleMarginControl = {
name: 'y_axis_title_margin',
config: {
type: 'SelectControl',
freeForm: true,
clearable: true,
label: t('Y Axis Title Margin'),
renderTrigger: true,
default: TITLE_MARGIN_OPTIONS[1],
choices: formatSelectOptions(TITLE_MARGIN_OPTIONS),
description: t('Changing this control takes effect instantly'),
},
};

export const titleControls: ControlPanelSectionConfig = {
label: t('Chart Title'),
tabOverride: 'customize',
@@ -47,21 +76,7 @@ export const titleControls: ControlPanelSectionConfig = {
},
},
],
[
{
name: 'x_axis_title_margin',
config: {
type: 'SelectControl',
freeForm: true,
clearable: true,
label: t('X Axis Title Margin'),
renderTrigger: true,
default: TITLE_MARGIN_OPTIONS[0],
choices: formatSelectOptions(TITLE_MARGIN_OPTIONS),
description: t('Changing this control takes effect instantly'),
},
},
],
[xAxisTitleMarginControl],
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for consistency with other charts, should you just bring the entire titleControls section here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are several reasons why I chose to use only margin controls:

  1. The y-axis is always hidden, as it's actually numerical instead of categorical. This is because when the subcategories are true, the height of each category may differ from each other. The built-in echarts categorical axis only breaks the axis on categories with equal heights.
  2. The margin of the y-axis should be controlled, even without a title (which is not the standard way for this section), because category names may be too long. This can be seen in the video.

However, I played around with these controls and found that all of them worked except for the last one (y_axis_title_position). When the value is set to Top, the padding for the y-axis applies to the top, rather than to the left, using the standard getPadding function. This contradicts (2).

If I manually apply padding to the left, then the title behaves weird: the more padding there is on the left, the more it moves to the top. So, I ended up using the entire titleControls section except for the y_axis_title_position control (6bc3df8).

P.S.: sorry for the long delay

[<ControlSubSectionHeader>{t('Y Axis')}</ControlSubSectionHeader>],
[
{
@@ -75,21 +90,7 @@ export const titleControls: ControlPanelSectionConfig = {
},
},
],
[
{
name: 'y_axis_title_margin',
config: {
type: 'SelectControl',
freeForm: true,
clearable: true,
label: t('Y Axis Title Margin'),
renderTrigger: true,
default: TITLE_MARGIN_OPTIONS[1],
choices: formatSelectOptions(TITLE_MARGIN_OPTIONS),
description: t('Changing this control takes effect instantly'),
},
},
],
[yAxisTitleMarginControl],
[
{
name: 'y_axis_title_position',
Original file line number Diff line number Diff line change
@@ -17,7 +17,12 @@
* specific language governing permissions and limitations
* under the License.
*/
import { QueryColumn, t, validateNonEmpty } from '@superset-ui/core';
import {
GenericDataType,
QueryColumn,
t,
validateNonEmpty,
} from '@superset-ui/core';
import {
ExtraControlProps,
SharedControlConfig,
@@ -52,6 +57,19 @@ type Control = {
* feature flags are set and when they're checked.
*/

function filterOptions(
options: (ColumnMeta | QueryColumn)[],
allowedDataTypes?: GenericDataType[],
) {
if (!allowedDataTypes) {
return options;
}
return options.filter(
o =>
o.type_generic !== undefined && allowedDataTypes.includes(o.type_generic),
);
}

export const dndGroupByControl: SharedControlConfig<
'DndColumnSelect' | 'SelectControl',
ColumnMeta
@@ -81,14 +99,20 @@ export const dndGroupByControl: SharedControlConfig<
const newState: ExtraControlProps = {};
const { datasource } = state;
if (datasource?.columns[0]?.hasOwnProperty('groupby')) {
const options = (datasource as Dataset).columns.filter(c => c.groupby);
const options = filterOptions(
(datasource as Dataset).columns.filter(c => c.groupby),
controlState?.allowedDataTypes,
);
if (controlState?.includeTime) {
options.unshift(DATASET_TIME_COLUMN_OPTION);
}
newState.options = options;
newState.savedMetrics = (datasource as Dataset).metrics || [];
} else {
const options = (datasource?.columns as QueryColumn[]) || [];
const options = filterOptions(
(datasource?.columns as QueryColumn[]) || [],
controlState?.allowedDataTypes,
);
if (controlState?.includeTime) {
options.unshift(QUERY_TIME_COLUMN_OPTION);
}
@@ -177,6 +201,19 @@ export const dndAdhocMetricControl: typeof dndAdhocMetricsControl = {
),
};

export const dndTooltipColumnsControl: typeof dndColumnsControl = {
...dndColumnsControl,
label: t('Tooltip (columns)'),
description: t('Columns to show in the tooltip.'),
};

export const dndTooltipMetricsControl: typeof dndAdhocMetricsControl = {
...dndAdhocMetricsControl,
label: t('Tooltip (metrics)'),
description: t('Metrics to show in the tooltip.'),
validators: [],
};

export const dndAdhocMetricControl2: typeof dndAdhocMetricControl = {
...dndAdhocMetricControl,
label: t('Right Axis Metric'),
Original file line number Diff line number Diff line change
@@ -45,6 +45,7 @@ import {
isDefined,
NO_TIME_RANGE,
validateMaxValue,
getColumnLabel,
} from '@superset-ui/core';

import {
@@ -82,6 +83,8 @@ import {
dndSeriesControl,
dndAdhocMetricControl2,
dndXAxisControl,
dndTooltipColumnsControl,
dndTooltipMetricsControl,
} from './dndControls';

const categoricalSchemeRegistry = getCategoricalSchemeRegistry();
@@ -373,6 +376,14 @@ const temporal_columns_lookup: SharedControlConfig<'HiddenControl'> = {
),
};

const zoomable: SharedControlConfig<'CheckboxControl'> = {
type: 'CheckboxControl',
label: t('Data Zoom'),
default: false,
renderTrigger: true,
description: t('Enable data zooming controls'),
};

const sort_by_metric: SharedControlConfig<'CheckboxControl'> = {
type: 'CheckboxControl',
label: t('Sort by metric'),
@@ -381,6 +392,26 @@ const sort_by_metric: SharedControlConfig<'CheckboxControl'> = {
),
};

const order_by_cols: SharedControlConfig<'SelectControl'> = {
type: 'SelectControl',
label: t('Ordering'),
description: t('Order results by selected columns'),
multi: true,
default: [],
shouldMapStateToProps: () => true,
mapStateToProps: ({ datasource }) => ({
choices: (datasource?.columns || [])
.map(col =>
[true, false].map(asc => [
JSON.stringify([col.column_name, asc]),
`${getColumnLabel(col.column_name)} [${asc ? 'asc' : 'desc'}]`,
]),
)
.flat(),
}),
resetOnHide: false,
};

export default {
metrics: dndAdhocMetricsControl,
metric: dndAdhocMetricControl,
@@ -392,6 +423,8 @@ export default {
secondary_metric: dndSecondaryMetricControl,
groupby: dndGroupByControl,
columns: dndColumnsControl,
tooltip_columns: dndTooltipColumnsControl,
tooltip_metrics: dndTooltipMetricsControl,
granularity,
granularity_sqla: dndGranularitySqlaControl,
time_grain_sqla,
@@ -417,8 +450,10 @@ export default {
legacy_order_by: dndSortByControl,
truncate_metric,
x_axis: dndXAxisControl,
zoomable,
show_empty_columns,
temporal_columns_lookup,
currency_format,
sort_by_metric,
order_by_cols,
};
Original file line number Diff line number Diff line change
@@ -54,6 +54,7 @@ export enum VizType {
Step = 'echarts_timeseries_step',
Sunburst = 'sunburst_v2',
Table = 'table',
Timeline = 'timeline',
TimePivot = 'time_pivot',
TimeTable = 'time_table',
Timeseries = 'echarts_timeseries',
Original file line number Diff line number Diff line change
@@ -54,7 +54,6 @@ const {
stack,
truncateYAxis,
yAxisBounds,
zoomable,
yAxisIndex,
} = DEFAULT_FORM_DATA;

@@ -302,18 +301,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>],
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
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 { EchartsTimelineChartTransformedProps } from './types';
import { EventHandlers } from '../types';

const { RadioButtonControl } = sharedControlComponents;

export default function EchartsTimeline(
props: EchartsTimelineChartTransformedProps,
) {
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}
/>
</>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
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,
},
]);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export const ELEMENT_HEIGHT_SCALE = 0.85 as const;

export enum Dimension {
StartTime = 'start_time',
EndTime = 'end_time',
Index = 'index',
SeriesCount = 'series_count',
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
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'],
],
},
{
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],
},
},
],
[
{
name: sections.xAxisTitleMarginControl.name,
config: {
...sections.xAxisTitleMarginControl.config,
default: 0,
},
},
],
['x_axis_time_format'],
[<ControlSubSectionHeader>{t('Y Axis')}</ControlSubSectionHeader>],
[
{
name: sections.yAxisTitleMarginControl.name,
config: {
...sections.yAxisTitleMarginControl.config,
default: 30,
},
},
],
[<ControlSubSectionHeader>{t('Tooltip')}</ControlSubSectionHeader>],
[tooltipTimeFormatControl],
[tooltipValuesFormatControl],
],
},
],
};

export default config;
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
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 EchartsTimelineChartPlugin extends EchartsChartPlugin {
constructor() {
super({
buildQuery,
controlPanel,
loadChart: () => import('./EchartsTimeline'),
metadata: {
behaviors: [
Behavior.InteractiveChart,
Behavior.DrillToDetail,
Behavior.DrillBy,
],
credits: ['https://echarts.apache.org'],
name: t('Timeline'),
description: t(
'Timeline chart visualizes important events over a time span. ' +
'Every data point displayed as a separate event along a ' +
'horizontal line.',
),
tags: [t('ECharts'), t('Time'), t('Featured')],
thumbnail,
exampleGallery: [{ url: example1 }, { url: example2 }],
},
transformProps,
});
}
}

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import {
ChartDataResponseResult,
ChartProps,
QueryFormColumn,
QueryFormData,
QueryFormMetric,
} from '@superset-ui/core';
import {
BaseTransformedProps,
CrossFilterTransformedProps,
LegendFormData,
} from '../types';

export type EchartsTimelineChartTransformedProps =
BaseTransformedProps<EchartsTimelineFormData> & CrossFilterTransformedProps;

export type EchartsTimelineFormData = QueryFormData &
LegendFormData & {
viz_type: 'echarts_timeline';
startTime: QueryFormColumn;
endTime: QueryFormColumn;
yAxis: QueryFormColumn;
tooltipMetrics: QueryFormMetric[];
tooltipColumns: QueryFormColumn[];
series?: QueryFormColumn;
xAxisTimeFormat?: string;
tooltipTimeFormat?: string;
tooltipValuesFormat?: string;
colorScheme?: string;
zoomable?: boolean;
xAxisTitleMargin?: number;
yAxisTitleMargin?: number;
xAxisTimeBounds?: [string | null, string | null];
subcategories?: boolean;
showExtraControls?: boolean;
};

export interface EchartsTimelineChartProps
extends ChartProps<EchartsTimelineFormData> {
formData: EchartsTimelineFormData;
queriesData: ChartDataResponseResult[];
}

export interface Cartesian2dCoordSys {
type: 'cartesian2d';
x: number;
y: number;
width: number;
height: number;
}
Original file line number Diff line number Diff line change
@@ -53,7 +53,6 @@ const {
seriesType,
truncateYAxis,
yAxisBounds,
zoomable,
} = DEFAULT_FORM_DATA;
const config: ControlPanelConfig = {
controlPanelSections: [
@@ -170,18 +169,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>],
[
Original file line number Diff line number Diff line change
@@ -52,7 +52,6 @@ const {
minorSplitLine,
truncateYAxis,
yAxisBounds,
zoomable,
orientation,
} = DEFAULT_FORM_DATA;

@@ -355,18 +354,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'),
Original file line number Diff line number Diff line change
@@ -54,7 +54,6 @@ const {
seriesType,
truncateYAxis,
yAxisBounds,
zoomable,
} = DEFAULT_FORM_DATA;
const config: ControlPanelConfig = {
controlPanelSections: [
@@ -157,18 +156,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>],
Original file line number Diff line number Diff line change
@@ -50,7 +50,6 @@ const {
rowLimit,
truncateYAxis,
yAxisBounds,
zoomable,
} = DEFAULT_FORM_DATA;
const config: ControlPanelConfig = {
controlPanelSections: [
@@ -99,18 +98,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>],
Original file line number Diff line number Diff line change
@@ -50,7 +50,6 @@ const {
rowLimit,
truncateYAxis,
yAxisBounds,
zoomable,
} = DEFAULT_FORM_DATA;
const config: ControlPanelConfig = {
controlPanelSections: [
@@ -99,18 +98,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>],
Original file line number Diff line number Diff line change
@@ -50,7 +50,6 @@ const {
rowLimit,
truncateYAxis,
yAxisBounds,
zoomable,
} = DEFAULT_FORM_DATA;
const config: ControlPanelConfig = {
controlPanelSections: [
@@ -151,18 +150,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>],
22 changes: 21 additions & 1 deletion superset-frontend/plugins/plugin-chart-echarts/src/controls.tsx
Original file line number Diff line number Diff line change
@@ -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: {
@@ -350,3 +360,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,
},
};
2 changes: 2 additions & 0 deletions superset-frontend/plugins/plugin-chart-echarts/src/index.ts
Original file line number Diff line number Diff line change
@@ -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 EchartsTimelineChartPlugin } from './Timeline';

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 TimelineTransformProps } from './Timeline/transformProps';

export { DEFAULT_FORM_DATA as TimeseriesDefaultFormData } from './Timeseries/constants';

17 changes: 17 additions & 0 deletions superset-frontend/plugins/plugin-chart-echarts/src/utils/series.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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)),
];
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { QueryFormData } from '@superset-ui/core';
import buildQuery from '../../src/Timeline/buildQuery';

describe('Timeline buildQuery', () => {
const formData: QueryFormData = {
datasource: '1__table',
viz_type: 'echarts_timeline',
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],
]);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
import { AxisType, ChartProps, supersetTheme } from '@superset-ui/core';
import {
LegendOrientation,
LegendType,
} from '@superset-ui/plugin-chart-echarts';
import transformProps from '../../src/Timeline/transformProps';
import {
EchartsTimelineChartProps,
EchartsTimelineFormData,
} from '../../src/Timeline/types';

describe('Timeline transformProps', () => {
const formData: EchartsTimelineFormData = {
viz_type: 'echarts_timeline',
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 EchartsTimelineChartProps,
);

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: {
max: Date.UTC(2025, 1, 1, 19, 0, 0),
min: undefined,
type: AxisType.Time,
nameGap: 0,
nameLocation: 'middle',
axisLabel: {
hideOverlap: true,
formatter: expect.anything(),
},
},
yAxis: {
type: AxisType.Value,
// always 0
min: 0,
// equals unique categories count
max: 2,
// always disabled because markLines are used instead
show: false,
nameGap: 0,
},
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: [
'start_time',
'end_time',
'index',
'series_count',
'startTime',
'endTime',
'Y Axis',
'tooltip_column',
'series',
],
});

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: [
'start_time',
'end_time',
'index',
'series_count',
'startTime',
'endTime',
'Y Axis',
'tooltip_column',
'series',
],
});
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'],
},
});
});
});
2 changes: 2 additions & 0 deletions superset-frontend/src/components/Chart/ChartRenderer.jsx
Original file line number Diff line number Diff line change
@@ -161,6 +161,8 @@ class ChartRenderer extends Component {
nextProps.labelsColorMap !== this.props.labelsColorMap ||
nextProps.formData.color_scheme !== this.props.formData.color_scheme ||
nextProps.formData.stack !== this.props.formData.stack ||
nextProps.formData.subcategories !==
this.props.formData.subcategories ||
nextProps.cacheBusterProp !== this.props.cacheBusterProp ||
nextProps.emitCrossFilters !== this.props.emitCrossFilters
);
31 changes: 31 additions & 0 deletions superset-frontend/src/components/TimePicker/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/**
* 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 {
TimePicker as AntdTimePicker,
TimePickerProps,
TimeRangePickerProps,
} from 'antd-v5';

export const TimePicker = (props: TimePickerProps) => (
<AntdTimePicker css={{ width: '100%' }} {...props} />
);

export const TimeRangePicker = (props: TimeRangePickerProps) => (
<AntdTimePicker.RangePicker css={{ width: '100%' }} {...props} />
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { useMemo } from 'react';
import dayjs from 'dayjs';
import { TimeRangePicker } from 'src/components/TimePicker';
import ControlHeader, { ControlHeaderProps } from '../../ControlHeader';

type TimeRangeValueType = [string, string];

export interface TimeRangeControlProps extends ControlHeaderProps {
value?: TimeRangeValueType;
onChange?: (value: TimeRangeValueType, errors: any) => void;
allowClear?: boolean;
showNow?: boolean;
allowEmpty?: [boolean, boolean];
}

export default function TimeRangeControl({
value: stringValue,
onChange,
allowClear,
showNow,
allowEmpty,
...rest
}: TimeRangeControlProps) {
const dayjsValue = useMemo(() => {
const ret: [dayjs.Dayjs | null, dayjs.Dayjs | null] = [null, null];
if (stringValue?.[0]) {
ret[0] = dayjs.utc(stringValue[0], 'HH:mm:ss');
}
if (stringValue?.[1]) {
ret[1] = dayjs.utc(stringValue[1], 'HH:mm:ss');
}
return ret;
}, [stringValue]);

return (
<div>
<ControlHeader {...rest} />
<TimeRangePicker
value={dayjsValue}
onChange={(_, stringValue) => onChange?.(stringValue, null)}
allowClear={allowClear}
showNow={showNow}
allowEmpty={allowEmpty}
/>
</div>
);
}
2 changes: 2 additions & 0 deletions superset-frontend/src/explore/components/controls/index.js
Original file line number Diff line number Diff line change
@@ -54,6 +54,7 @@ import LayerConfigsControl from './LayerConfigsControl/LayerConfigsControl';
import MapViewControl from './MapViewControl/MapViewControl';
import ZoomConfigControl from './ZoomConfigControl/ZoomConfigControl';
import NumberControl from './NumberControl';
import TimeRangeControl from './TimeRangeControl';

const controlMap = {
AnnotationLayerControl,
@@ -92,6 +93,7 @@ const controlMap = {
TimeOffsetControl,
ZoomConfigControl,
NumberControl,
TimeRangeControl,
...sharedControlComponents,
};
export default controlMap;
2 changes: 2 additions & 0 deletions superset-frontend/src/visualizations/presets/MainPreset.js
Original file line number Diff line number Diff line change
@@ -68,6 +68,7 @@ import {
EchartsWaterfallChartPlugin,
BigNumberPeriodOverPeriodChartPlugin,
EchartsHeatmapChartPlugin,
EchartsTimelineChartPlugin,
} from '@superset-ui/plugin-chart-echarts';
import {
SelectFilterPlugin,
@@ -158,6 +159,7 @@ export default class MainPreset extends Preset {
}),
new EchartsHeatmapChartPlugin().configure({ key: VizType.Heatmap }),
new EchartsHistogramChartPlugin().configure({ key: VizType.Histogram }),
new EchartsTimelineChartPlugin().configure({ key: VizType.Timeline }),
new SelectFilterPlugin().configure({ key: FilterPlugins.Select }),
new RangeFilterPlugin().configure({ key: FilterPlugins.Range }),
new TimeFilterPlugin().configure({ key: FilterPlugins.Time }),