diff --git a/superset-frontend/packages/superset-ui-chart-controls/src/sections/chartTitle.tsx b/superset-frontend/packages/superset-ui-chart-controls/src/sections/chartTitle.tsx index 314e983c589ae..eda3ac33152d4 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/src/sections/chartTitle.tsx +++ b/superset-frontend/packages/superset-ui-chart-controls/src/sections/chartTitle.tsx @@ -21,8 +21,10 @@ import { t } from '@superset-ui/core'; import { ControlPanelSectionConfig } from '../types'; import { formatSelectOptions } from '../utils'; -const TITLE_MARGIN_OPTIONS: number[] = [15, 30, 50, 75, 100, 125, 150, 200]; -const TITLE_POSITION_OPTIONS: string[] = ['Left', 'Top']; +export const TITLE_MARGIN_OPTIONS: number[] = [ + 15, 30, 50, 75, 100, 125, 150, 200, +]; +export const TITLE_POSITION_OPTIONS: string[] = ['Left', 'Top']; export const titleControls: ControlPanelSectionConfig = { label: t('Chart Title'), tabOverride: 'customize', diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/Bar/controlPanel.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/Bar/controlPanel.tsx index a3b74aa12f4fe..8716bffe2d389 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/Bar/controlPanel.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/Bar/controlPanel.tsx @@ -21,8 +21,11 @@ import { FeatureFlag, isFeatureEnabled, t } from '@superset-ui/core'; import { ControlPanelConfig, ControlPanelsContainerProps, + ControlSetRow, + ControlStateMapping, D3_TIME_FORMAT_DOCS, emitFilterControl, + formatSelectOptions, sections, sharedControls, } from '@superset-ui/chart-controls'; @@ -30,6 +33,7 @@ import { import { DEFAULT_FORM_DATA, EchartsTimeseriesContributionType, + OrientationType, } from '../../types'; import { legendSection, @@ -49,7 +53,217 @@ const { yAxisBounds, zoomable, xAxisLabelRotation, + orientation, } = DEFAULT_FORM_DATA; + +function createAxisTitleControl(axis: 'x' | 'y'): ControlSetRow[] { + const isXAxis = axis === 'x'; + const isVertical = (controls: ControlStateMapping) => + Boolean(controls?.orientation.value === OrientationType.vertical); + const isHorizental = (controls: ControlStateMapping) => + Boolean(controls?.orientation.value === OrientationType.horizontal); + return [ + [ + { + name: 'x_axis_title', + config: { + type: 'TextControl', + label: t('Axis Title'), + renderTrigger: true, + default: '', + description: t('Changing this control takes effect instantly'), + visibility: ({ controls }: ControlPanelsContainerProps) => + isXAxis ? isVertical(controls) : isHorizental(controls), + }, + }, + ], + [ + { + name: 'x_axis_title_margin', + config: { + type: 'SelectControl', + freeForm: true, + clearable: true, + label: t('AXIS TITLE MARGIN'), + renderTrigger: true, + default: sections.TITLE_MARGIN_OPTIONS[0], + choices: formatSelectOptions(sections.TITLE_MARGIN_OPTIONS), + description: t('Changing this control takes effect instantly'), + visibility: ({ controls }: ControlPanelsContainerProps) => + isXAxis ? isVertical(controls) : isHorizental(controls), + }, + }, + ], + [ + { + name: 'y_axis_title', + config: { + type: 'TextControl', + label: t('Axis Title'), + renderTrigger: true, + default: '', + description: t('Changing this control takes effect instantly'), + visibility: ({ controls }: ControlPanelsContainerProps) => + isXAxis ? isHorizental(controls) : isVertical(controls), + }, + }, + ], + [ + { + name: 'y_axis_title_margin', + config: { + type: 'SelectControl', + freeForm: true, + clearable: true, + label: t('AXIS TITLE MARGIN'), + renderTrigger: true, + default: sections.TITLE_MARGIN_OPTIONS[0], + choices: formatSelectOptions(sections.TITLE_MARGIN_OPTIONS), + description: t('Changing this control takes effect instantly'), + visibility: ({ controls }: ControlPanelsContainerProps) => + isXAxis ? isHorizental(controls) : isVertical(controls), + }, + }, + ], + [ + { + name: 'y_axis_title_position', + config: { + type: 'SelectControl', + freeForm: true, + clearable: false, + label: t('AXIS TITLE POSITION'), + renderTrigger: true, + default: sections.TITLE_POSITION_OPTIONS[0], + choices: formatSelectOptions(sections.TITLE_POSITION_OPTIONS), + description: t('Changing this control takes effect instantly'), + visibility: ({ controls }: ControlPanelsContainerProps) => + isXAxis ? isHorizental(controls) : isVertical(controls), + }, + }, + ], + ]; +} + +function createAxisControl(axis: 'x' | 'y'): ControlSetRow[] { + const isXAxis = axis === 'x'; + const isVertical = (controls: ControlStateMapping) => + Boolean(controls?.orientation.value === OrientationType.vertical); + const isHorizental = (controls: ControlStateMapping) => + Boolean(controls?.orientation.value === OrientationType.horizontal); + return [ + [ + { + name: 'x_axis_time_format', + config: { + ...sharedControls.x_axis_time_format, + default: 'smart_date', + description: `${D3_TIME_FORMAT_DOCS}. ${t( + 'When using other than adaptive formatting, labels may overlap.', + )}`, + visibility: ({ controls }: ControlPanelsContainerProps) => + isXAxis ? isVertical(controls) : isHorizental(controls), + }, + }, + ], + [ + { + name: 'xAxisLabelRotation', + config: { + type: 'SelectControl', + freeForm: true, + clearable: false, + label: t('Rotate axis label'), + choices: [ + [0, '0°'], + [45, '45°'], + ], + default: xAxisLabelRotation, + renderTrigger: true, + description: t( + 'Input field supports custom rotation. e.g. 30 for 30°', + ), + visibility: ({ controls }: ControlPanelsContainerProps) => + isXAxis ? isVertical(controls) : isHorizental(controls), + }, + }, + ], + [ + { + name: 'y_axis_format', + config: { + ...sharedControls.y_axis_format, + label: t('Axis Format'), + visibility: ({ controls }: ControlPanelsContainerProps) => + isXAxis ? isHorizental(controls) : isVertical(controls), + }, + }, + ], + [ + { + name: 'logAxis', + config: { + type: 'CheckboxControl', + label: t('Logarithmic axis'), + renderTrigger: true, + default: logAxis, + description: t('Logarithmic axis'), + visibility: ({ controls }: ControlPanelsContainerProps) => + isXAxis ? isHorizental(controls) : isVertical(controls), + }, + }, + ], + [ + { + name: 'minorSplitLine', + config: { + type: 'CheckboxControl', + label: t('Minor Split Line'), + renderTrigger: true, + default: minorSplitLine, + description: t('Draw split lines for minor axis ticks'), + visibility: ({ controls }: ControlPanelsContainerProps) => + isXAxis ? isHorizental(controls) : isVertical(controls), + }, + }, + ], + [ + { + name: 'truncateYAxis', + config: { + type: 'CheckboxControl', + label: t('Truncate Axis'), + default: truncateYAxis, + renderTrigger: true, + description: t('It’s not recommended to truncate axis in Bar chart.'), + visibility: ({ controls }: ControlPanelsContainerProps) => + isXAxis ? isHorizental(controls) : isVertical(controls), + }, + }, + ], + [ + { + name: 'y_axis_bounds', + config: { + type: 'BoundsControl', + label: t('Axis Bounds'), + renderTrigger: true, + default: yAxisBounds, + description: t( + 'Bounds for the axis. When left empty, the bounds are ' + + 'dynamically defined based on the min/max of the data. Note that ' + + "this feature will only expand the axis range. It won't " + + "narrow the data's extent.", + ), + visibility: ({ controls }: ControlPanelsContainerProps) => + Boolean(controls?.truncateYAxis?.value) && + (isXAxis ? isHorizental(controls) : isVertical(controls)), + }, + }, + ], + ]; +} + const config: ControlPanelConfig = { controlPanelSections: [ sections.legacyTimeseriesTime, @@ -87,7 +301,39 @@ const config: ControlPanelConfig = { sections.advancedAnalyticsControls, sections.annotationsAndLayersControls, sections.forecastIntervalControls, - sections.titleControls, + { + label: t('Chart Orientation'), + expanded: true, + controlSetRows: [ + [ + { + name: 'orientation', + config: { + type: 'RadioButtonControl', + renderTrigger: true, + label: t('Bar orientation'), + default: orientation, + options: [ + [OrientationType.vertical, t('Vertical')], + [OrientationType.horizontal, t('Horizontal')], + ], + description: t('Orientation of bar chart'), + }, + }, + ], + ], + }, + { + label: t('Chart Title'), + tabOverride: 'customize', + expanded: true, + controlSetRows: [ + [
{t('X Axis')}
], + ...createAxisTitleControl('x'), + [
{t('Y Axis')}
], + ...createAxisTitleControl('y'), + ], + }, { label: t('Chart Options'), expanded: true, @@ -140,101 +386,10 @@ const config: ControlPanelConfig = { ], ...legendSection, [
{t('X Axis')}
], - [ - { - name: 'x_axis_time_format', - config: { - ...sharedControls.x_axis_time_format, - default: 'smart_date', - description: `${D3_TIME_FORMAT_DOCS}. ${t( - 'When using other than adaptive formatting, labels may overlap.', - )}`, - }, - }, - ], - [ - { - name: 'xAxisLabelRotation', - config: { - type: 'SelectControl', - freeForm: true, - clearable: false, - label: t('Rotate x axis label'), - choices: [ - [0, '0°'], - [45, '45°'], - ], - default: xAxisLabelRotation, - renderTrigger: true, - description: t( - 'Input field supports custom rotation. e.g. 30 for 30°', - ), - }, - }, - ], - // eslint-disable-next-line react/jsx-key + ...createAxisControl('x'), ...richTooltipSection, - // eslint-disable-next-line react/jsx-key [
{t('Y Axis')}
], - - ['y_axis_format'], - [ - { - name: 'logAxis', - config: { - type: 'CheckboxControl', - label: t('Logarithmic y-axis'), - renderTrigger: true, - default: logAxis, - description: t('Logarithmic y-axis'), - }, - }, - ], - [ - { - name: 'minorSplitLine', - config: { - type: 'CheckboxControl', - label: t('Minor Split Line'), - renderTrigger: true, - default: minorSplitLine, - description: t('Draw split lines for minor y-axis ticks'), - }, - }, - ], - [ - { - name: 'truncateYAxis', - config: { - type: 'CheckboxControl', - label: t('Truncate Y Axis'), - default: truncateYAxis, - renderTrigger: true, - description: t( - 'It’s not recommended to truncate y-axis in Bar chart.', - ), - }, - }, - ], - [ - { - name: 'y_axis_bounds', - config: { - type: 'BoundsControl', - label: t('Y Axis Bounds'), - renderTrigger: true, - default: yAxisBounds, - description: t( - 'Bounds for the Y-axis. When left empty, the bounds are ' + - 'dynamically defined based on the min/max of the data. Note that ' + - "this feature will only expand the axis range. It won't " + - "narrow the data's extent.", - ), - visibility: ({ controls }: ControlPanelsContainerProps) => - Boolean(controls?.truncateYAxis?.value), - }, - }, - ], + ...createAxisControl('y'), ], }, ], diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts index 3a9491153b65d..85fa656f2ed01 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts @@ -39,6 +39,7 @@ import { EchartsTimeseriesFormData, EchartsTimeseriesSeriesType, TimeseriesChartTransformedProps, + OrientationType, } from './types'; import { ForecastSeriesEnum, ForecastValue } from '../types'; import { parseYAxisBound } from '../utils/controls'; @@ -138,16 +139,19 @@ export default function transformProps( yAxisTitlePosition, sliceId, timeGrainSqla, + orientation, }: EchartsTimeseriesFormData = { ...DEFAULT_FORM_DATA, ...formData }; const colorScale = CategoricalColorNamespace.getScale(colorScheme as string); const rebasedData = rebaseForecastDatum(data, verboseMap); const xAxisCol = verboseMap[xAxisOrig] || getColumnLabel(xAxisOrig || DTTM_ALIAS); + const isHorizontal = orientation === OrientationType.horizontal; const rawSeries = extractSeries(rebasedData, { fillNeighborValue: stack && !forecastEnabled ? 0 : undefined, xAxis: xAxisCol, removeNulls: seriesType === EchartsTimeseriesSeriesType.Scatter, + isHorizontal, }); const seriesContexts = extractForecastSeriesContexts( Object.values(rawSeries).map(series => series.name as string), @@ -213,6 +217,7 @@ export default function transformProps( thresholdValues, richTooltip, sliceId, + isHorizontal, }); if (transformedSeries) series.push(transformedSeries); }); @@ -325,57 +330,66 @@ export default function transformProps( .map(entry => entry.name || '') .concat(extractAnnotationLabels(annotationLayers, annotationData)); + let xAxis: any = { + type: xAxisType, + name: xAxisTitle, + nameGap: convertInteger(xAxisTitleMargin), + nameLocation: 'middle', + axisLabel: { + hideOverlap: true, + formatter: xAxisFormatter, + rotate: xAxisLabelRotation, + }, + minInterval: + xAxisType === 'time' && timeGrainSqla + ? TimeGrainToTimestamp[timeGrainSqla] + : 0, + }; + let yAxis: any = { + ...defaultYAxis, + type: logAxis ? 'log' : 'value', + min, + max, + minorTick: { show: true }, + minorSplitLine: { show: minorSplitLine }, + axisLabel: { formatter }, + scale: truncateYAxis, + name: yAxisTitle, + nameGap: convertInteger(yAxisTitleMargin), + nameLocation: yAxisTitlePosition === 'Left' ? 'middle' : 'end', + }; + + if (isHorizontal) { + [xAxis, yAxis] = [yAxis, xAxis]; + [padding.bottom, padding.left] = [padding.left, padding.bottom]; + } + const echartOptions: EChartsCoreOption = { useUTC: true, grid: { ...defaultGrid, ...padding, }, - xAxis: { - type: xAxisType, - name: xAxisTitle, - nameGap: convertInteger(xAxisTitleMargin), - nameLocation: 'middle', - axisLabel: { - hideOverlap: true, - formatter: xAxisFormatter, - rotate: xAxisLabelRotation, - }, - minInterval: - xAxisType === 'time' && timeGrainSqla - ? TimeGrainToTimestamp[timeGrainSqla] - : 0, - }, - yAxis: { - ...defaultYAxis, - type: logAxis ? 'log' : 'value', - min, - max, - minorTick: { show: true }, - minorSplitLine: { show: minorSplitLine }, - axisLabel: { formatter }, - scale: truncateYAxis, - name: yAxisTitle, - nameGap: convertInteger(yAxisTitleMargin), - nameLocation: yAxisTitlePosition === 'Left' ? 'middle' : 'end', - }, + xAxis, + yAxis, tooltip: { ...defaultTooltip, appendToBody: true, trigger: richTooltip ? 'axis' : 'item', formatter: (params: any) => { + const [xIndex, yIndex] = isHorizontal ? [1, 0] : [0, 1]; const xValue: number = richTooltip - ? params[0].value[0] - : params.value[0]; + ? params[0].value[xIndex] + : params.value[xIndex]; const forecastValue: any[] = richTooltip ? params : [params]; if (richTooltip && tooltipSortByMetric) { - forecastValue.sort((a, b) => b.data[1] - a.data[1]); + forecastValue.sort((a, b) => b.data[yIndex] - a.data[yIndex]); } const rows: Array = [`${tooltipFormatter(xValue)}`]; const forecastValues: Record = - extractForecastValuesFromTooltipParams(forecastValue); + extractForecastValuesFromTooltipParams(forecastValue, isHorizontal); Object.keys(forecastValues).forEach(key => { const value = forecastValues[key]; diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformers.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformers.ts index 48b7763458e3c..d514f969d623f 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformers.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformers.ts @@ -86,6 +86,7 @@ export function transformSeries( richTooltip?: boolean; seriesKey?: OptionName; sliceId?: number; + isHorizontal?: boolean; }, ): SeriesOption | undefined { const { name } = series; @@ -108,6 +109,7 @@ export function transformSeries( richTooltip, seriesKey, sliceId, + isHorizontal = false, } = opts; const contexts = seriesContexts[name || ''] || []; const hasForecast = @@ -217,14 +219,10 @@ export function transformSeries( symbolSize: markerSize, label: { show: !!showValue, - position: 'top', + position: isHorizontal ? 'right' : 'top', formatter: (params: any) => { - const { - value: [, numericValue], - dataIndex, - seriesIndex, - seriesName, - } = params; + const { value, dataIndex, seriesIndex, seriesName } = params; + const numericValue = isHorizontal ? value[0] : value[1]; const isSelectedLegend = currentSeries.legend === seriesName; if (!formatter) return numericValue; if (!stack || isSelectedLegend) return formatter(numericValue); diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/types.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/types.ts index 81b274632a2c2..0d2499ccfcf6d 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/types.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/types.ts @@ -38,6 +38,11 @@ export enum EchartsTimeseriesContributionType { Column = 'column', } +export enum OrientationType { + vertical = 'vertical', + horizontal = 'horizontal', +} + export enum EchartsTimeseriesSeriesType { Line = 'line', Scatter = 'scatter', @@ -82,6 +87,7 @@ export type EchartsTimeseriesFormData = QueryFormData & { showValue: boolean; onlyTotal: boolean; percentageThreshold: number; + orientation?: OrientationType; } & EchartsLegendFormData & EchartsTitleFormData; @@ -119,6 +125,7 @@ export const DEFAULT_FORM_DATA: EchartsTimeseriesFormData = { showValue: false, onlyTotal: false, percentageThreshold: 0, + orientation: OrientationType.vertical, ...DEFAULT_TITLE_FORM_DATA, }; diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/utils/forecast.ts b/superset-frontend/plugins/plugin-chart-echarts/src/utils/forecast.ts index 63de4f4f6592a..94e4630bf455e 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/utils/forecast.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/utils/forecast.ts @@ -53,12 +53,13 @@ export const extractForecastSeriesContexts = ( export const extractForecastValuesFromTooltipParams = ( params: any[], + isHorizontal = false, ): Record => { const values: Record = {}; params.forEach(param => { const { marker, seriesId, value } = param; const context = extractForecastSeriesContext(seriesId); - const numericValue = (value as [Date, number])[1]; + const numericValue = isHorizontal ? value[0] : value[1]; if (numericValue) { if (!(context.name in values)) values[context.name] = { diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/utils/series.ts b/superset-frontend/plugins/plugin-chart-echarts/src/utils/series.ts index a262251b541e8..4da3681b7e231 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/utils/series.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/utils/series.ts @@ -42,9 +42,15 @@ export function extractSeries( fillNeighborValue?: number; xAxis?: string; removeNulls?: boolean; + isHorizontal?: boolean; } = {}, ): SeriesOption[] { - const { fillNeighborValue, xAxis = DTTM_ALIAS, removeNulls = false } = opts; + const { + fillNeighborValue, + xAxis = DTTM_ALIAS, + removeNulls = false, + isHorizontal = false, + } = opts; if (data.length === 0) return []; const rows: DataRecord[] = data.map(datum => ({ ...datum, @@ -69,7 +75,8 @@ export function extractSeries( : row[key], ]; }) - .filter(obs => !removeNulls || (obs[0] !== null && obs[1] !== null)), + .filter(obs => !removeNulls || (obs[0] !== null && obs[1] !== null)) + .map(obs => (isHorizontal ? [obs[1], obs[0]] : obs)), })); }