diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/Scatter/controlPanel.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/Scatter/controlPanel.tsx index a955da37d52f..12599fcd6a38 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/Scatter/controlPanel.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/Scatter/controlPanel.tsx @@ -22,14 +22,19 @@ import { GenericDataType } from '@apache-superset/core/common'; import { checkColumnType, ControlPanelConfig, + ControlPanelSectionConfig, ControlPanelsContainerProps, + ControlSetRow, + ControlStateMapping, ControlSubSectionHeader, D3_TIME_FORMAT_DOCS, + formatSelectOptions, getStandardizedControls, sections, sharedControls, } from '@superset-ui/chart-controls'; +import { OrientationType } from '../../types'; import { DEFAULT_FORM_DATA, TIME_SERIES_DESCRIPTION_TEXT, @@ -51,18 +56,342 @@ const { logAxis, markerEnabled, markerSize, + maxMarkerSize, + minMarkerSize, minorSplitLine, + orientation, rowLimit, truncateYAxis, yAxisBounds, } = DEFAULT_FORM_DATA; + +const isHorizontal = (controls: ControlStateMapping) => + controls?.orientation?.value === OrientationType.Horizontal; +const isVertical = (controls: ControlStateMapping) => !isHorizontal(controls); + +function createAxisTitleControl(axis: 'x' | 'y'): ControlSetRow[] { + const isXAxis = axis === 'x'; + return [ + [ + { + name: 'x_axis_title', + config: { + type: 'TextControl', + label: t('Axis Title'), + renderTrigger: true, + default: '', + visibility: ({ controls }: ControlPanelsContainerProps) => + isXAxis ? isVertical(controls) : isHorizontal(controls), + disableStash: true, + resetOnHide: false, + }, + }, + ], + [ + { + name: 'x_axis_title_margin', + config: { + type: 'SelectControl', + freeForm: true, + clearable: true, + label: t('Axis title margin'), + renderTrigger: true, + default: sections.TITLE_MARGIN_OPTIONS[3], + choices: formatSelectOptions(sections.TITLE_MARGIN_OPTIONS), + visibility: ({ controls }: ControlPanelsContainerProps) => + isXAxis ? isVertical(controls) : isHorizontal(controls), + disableStash: true, + resetOnHide: false, + }, + }, + ], + [ + { + name: 'y_axis_title', + config: { + type: 'TextControl', + label: t('Axis Title'), + renderTrigger: true, + default: '', + visibility: ({ controls }: ControlPanelsContainerProps) => + isXAxis ? isHorizontal(controls) : isVertical(controls), + disableStash: true, + resetOnHide: false, + }, + }, + ], + [ + { + name: 'y_axis_title_margin', + config: { + type: 'SelectControl', + freeForm: true, + clearable: true, + label: t('Axis title margin'), + renderTrigger: true, + default: sections.TITLE_MARGIN_OPTIONS[4], + choices: formatSelectOptions(sections.TITLE_MARGIN_OPTIONS), + visibility: ({ controls }: ControlPanelsContainerProps) => + isXAxis ? isHorizontal(controls) : isVertical(controls), + disableStash: true, + resetOnHide: false, + }, + }, + ], + [ + { + 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][0], + choices: sections.TITLE_POSITION_OPTIONS, + visibility: ({ controls }: ControlPanelsContainerProps) => + isXAxis ? isHorizontal(controls) : isVertical(controls), + disableStash: true, + resetOnHide: false, + }, + }, + ], + ]; +} + +function createAxisControl(axis: 'x' | 'y'): ControlSetRow[] { + const isXAxis = axis === 'x'; + // The dimension (x_axis) controls follow the dimension axis: they show + // under "X Axis" when vertical and under "Y Axis" when horizontal. The + // metric controls follow the opposite axis. + const showsDimensionAxis = (controls: ControlStateMapping) => + isXAxis ? isVertical(controls) : isHorizontal(controls); + const showsMetricAxis = (controls: ControlStateMapping) => + isXAxis ? isHorizontal(controls) : isVertical(controls); + return [ + [ + { + name: 'x_axis_time_format', + config: { + ...sharedControls.x_axis_time_format, + default: 'smart_date', + description: `${D3_TIME_FORMAT_DOCS}. ${TIME_SERIES_DESCRIPTION_TEXT}`, + visibility: ({ controls }: ControlPanelsContainerProps) => + showsDimensionAxis(controls) && + checkColumnType( + getColumnLabel(controls?.x_axis?.value as QueryFormColumn), + controls?.datasource?.datasource, + [GenericDataType.Temporal], + ), + disableStash: true, + resetOnHide: false, + }, + }, + ], + [ + { + name: 'x_axis_number_format', + config: { + ...sharedControls.x_axis_number_format, + default: '~g', + mapStateToProps: undefined, + visibility: ({ controls }: ControlPanelsContainerProps) => + showsDimensionAxis(controls) && + checkColumnType( + getColumnLabel(controls?.x_axis?.value as QueryFormColumn), + controls?.datasource?.datasource, + [GenericDataType.Numeric], + ), + disableStash: true, + resetOnHide: false, + }, + }, + ], + [ + { + name: xAxisLabelRotation.name, + config: { + ...xAxisLabelRotation.config, + visibility: ({ controls }: ControlPanelsContainerProps) => + showsDimensionAxis(controls), + disableStash: true, + resetOnHide: false, + }, + }, + ], + [ + { + name: xAxisLabelInterval.name, + config: { + ...xAxisLabelInterval.config, + visibility: ({ controls }: ControlPanelsContainerProps) => + showsDimensionAxis(controls), + disableStash: true, + resetOnHide: false, + }, + }, + ], + [ + { + name: 'y_axis_format', + config: { + ...sharedControls.y_axis_format, + label: t('Axis Format'), + visibility: ({ controls }: ControlPanelsContainerProps) => + showsMetricAxis(controls), + disableStash: true, + resetOnHide: false, + }, + }, + ], + ['currency_format'], + [ + { + name: 'logAxis', + config: { + type: 'CheckboxControl', + label: t('Logarithmic axis'), + renderTrigger: true, + default: logAxis, + description: t('Logarithmic axis'), + visibility: ({ controls }: ControlPanelsContainerProps) => + showsMetricAxis(controls), + disableStash: true, + resetOnHide: false, + }, + }, + ], + [ + { + 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) => + showsMetricAxis(controls), + disableStash: true, + resetOnHide: false, + }, + }, + ], + [ + { + name: 'truncateYAxis', + config: { + type: 'CheckboxControl', + label: t('Truncate Axis'), + default: truncateYAxis, + renderTrigger: true, + description: t( + 'Truncate the metric axis. Can be overridden by specifying a min or max bound.', + ), + visibility: ({ controls }: ControlPanelsContainerProps) => + showsMetricAxis(controls), + disableStash: true, + resetOnHide: false, + }, + }, + ], + [ + { + 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) && + showsMetricAxis(controls), + disableStash: true, + resetOnHide: false, + }, + }, + ], + ]; +} + +const sizeMetricRow: ControlSetRow = [ + { + name: 'size', + config: { + ...sharedControls.size, + label: t('Dot size metric'), + description: t( + 'Optional metric used to scale the size of each dot. Dot areas are ' + + 'scaled linearly between the minimum and maximum dot size.', + ), + validators: [], + }, + }, +]; + +const queryRows: ControlSetRow[] = [ + ...sections.echartsTimeSeriesQueryWithXAxisSort.controlSetRows, +]; +const groupbyRowIndex = queryRows.findIndex( + row => row.length === 1 && row[0] === 'groupby', +); +queryRows.splice( + groupbyRowIndex === -1 ? queryRows.length : groupbyRowIndex + 1, + 0, + sizeMetricRow, +); +const querySection: ControlPanelSectionConfig = { + ...sections.echartsTimeSeriesQueryWithXAxisSort, + controlSetRows: queryRows, +}; + const config: ControlPanelConfig = { controlPanelSections: [ - sections.echartsTimeSeriesQueryWithXAxisSort, + querySection, sections.advancedAnalyticsControls, sections.annotationsAndLayersControls, sections.forecastIntervalControls, - sections.titleControls, + { + label: t('Chart Orientation'), + expanded: true, + controlSetRows: [ + [ + { + name: 'orientation', + config: { + type: 'RadioButtonControl', + renderTrigger: true, + label: t('Chart orientation'), + default: orientation, + options: [ + [OrientationType.Vertical, t('Vertical')], + [OrientationType.Horizontal, t('Horizontal')], + ], + description: t( + 'Orientation of the chart. Horizontal places the dimension on the y-axis and the metric on the x-axis.', + ), + }, + }, + ], + ], + }, + { + 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, @@ -99,115 +428,56 @@ const config: ControlPanelConfig = { 'Size of marker. Also applies to forecast observations.', ), visibility: ({ controls }: ControlPanelsContainerProps) => - Boolean(controls?.markerEnabled?.value), + Boolean(controls?.markerEnabled?.value) && + !controls?.size?.value, }, }, ], - ['zoomable'], - [minorTicks], - ...legendSection, - [{t('X Axis')}], - [ { - name: 'x_axis_time_format', - config: { - ...sharedControls.x_axis_time_format, - default: 'smart_date', - description: `${D3_TIME_FORMAT_DOCS}. ${TIME_SERIES_DESCRIPTION_TEXT}`, - visibility: ({ controls }: ControlPanelsContainerProps) => - checkColumnType( - getColumnLabel(controls?.x_axis?.value as QueryFormColumn), - controls?.datasource?.datasource, - [GenericDataType.Temporal], - ), - disableStash: true, - resetOnHide: false, - }, - }, - { - name: 'x_axis_number_format', + name: 'minMarkerSize', config: { - ...sharedControls.x_axis_number_format, - default: '~g', - mapStateToProps: undefined, - visibility: ({ controls }: ControlPanelsContainerProps) => - checkColumnType( - getColumnLabel(controls?.x_axis?.value as QueryFormColumn), - controls?.datasource?.datasource, - [GenericDataType.Numeric], - ), - }, - }, - ], - [xAxisLabelRotation], - [xAxisLabelInterval], - [forceMaxInterval], - // eslint-disable-next-line react/jsx-key - ...richTooltipSection, - // eslint-disable-next-line react/jsx-key - [{t('Y Axis')}], - ['y_axis_format'], - ['currency_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'), - }, - }, - ], - [truncateXAxis], - [xAxisBounds], - [ - { - name: 'truncateYAxis', - config: { - type: 'CheckboxControl', - label: t('Truncate Y Axis'), - default: truncateYAxis, + type: 'SliderControl', + label: t('Minimum dot size'), renderTrigger: true, + min: 1, + max: 100, + default: minMarkerSize, description: t( - 'Truncate Y Axis. Can be overridden by specifying a min or max bound.', + 'Size of the dot representing the smallest value of the dot size metric.', ), + visibility: ({ controls }: ControlPanelsContainerProps) => + Boolean(controls?.size?.value), }, }, - ], - [ { - name: 'y_axis_bounds', + name: 'maxMarkerSize', config: { - type: 'BoundsControl', - label: t('Y Axis Bounds'), + type: 'SliderControl', + label: t('Maximum dot size'), renderTrigger: true, - default: yAxisBounds, + min: 1, + max: 100, + default: maxMarkerSize, 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.", + 'Size of the dot representing the largest value of the dot size metric.', ), visibility: ({ controls }: ControlPanelsContainerProps) => - Boolean(controls?.truncateYAxis?.value), + Boolean(controls?.size?.value), }, }, ], + ['zoomable'], + [minorTicks], + ...legendSection, + [{t('X Axis')}], + ...createAxisControl('x'), + [truncateXAxis], + [xAxisBounds], + [forceMaxInterval], + ...richTooltipSection, + [{t('Y Axis')}], + ...createAxisControl('y'), ], }, ], diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/constants.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/constants.ts index cdbab53d4f18..71430e870c9c 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/constants.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/constants.ts @@ -64,6 +64,8 @@ export const DEFAULT_FORM_DATA: EchartsTimeseriesFormData = { logAxis: false, markerEnabled: false, markerSize: 6, + maxMarkerSize: 30, + minMarkerSize: 5, minorSplitLine: false, opacity: 0.2, orderDesc: true, 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 4a0b8f5baf6f..361dd4317413 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts @@ -25,6 +25,7 @@ import { buildCustomFormatters, CategoricalColorNamespace, CurrencyFormatter, + DataRecordValue, ensureIsArray, tooltipHtml, getCustomFormatter, @@ -78,6 +79,7 @@ import { extractSeries, extractShowValueIndexes, extractTooltipKeys, + getAreaScaledSymbolSize, getAxisType, getColtypesMapping, getHorizontalLegendAvailableWidth, @@ -228,6 +230,8 @@ export default function transformProps( logAxis, markerEnabled, markerSize, + maxMarkerSize = 30, + minMarkerSize = 5, metrics, minorSplitLine, minorTicks, @@ -239,6 +243,7 @@ export default function transformProps( seriesType, showLegend, showValue, + size, colorByPrimaryAxis, sliceId, sortSeriesType, @@ -321,7 +326,7 @@ export default function transformProps( seriesType, ); - const [rawSeries, sortedTotalValues, minPositiveValue] = extractSeries( + const [allRawSeries, sortedTotalValues, minPositiveValue] = extractSeries( rebasedData, { fillNeighborValue: stack && !forecastEnabled ? 0 : undefined, @@ -337,6 +342,94 @@ export default function transformProps( xAxisType, }, ); + + // Dot size by metric (scatter): the size metric's series are excluded from + // rendering and instead provide per-point values that scale each marker's + // area between minMarkerSize and maxMarkerSize. + const sizeMetricLabel = + seriesType === EchartsTimeseriesSeriesType.Scatter && size + ? getMetricLabel(size) + : undefined; + const sizeSeriesLabel = isDefined(sizeMetricLabel) + ? (verboseMap[sizeMetricLabel!] ?? sizeMetricLabel) + : undefined; + const valueMetricLabels = ensureIsArray(metrics) + .map(getMetricLabel) + .map(label => verboseMap[label] ?? label); + // When the size metric is also a value metric, the query dedupes them into a + // single column, so each point's own value doubles as its size value. + const sizeIsValueMetric = isDefined(sizeSeriesLabel) + ? valueMetricLabels.includes(sizeSeriesLabel!) + : false; + const isSizeSeries = (name: string) => + isDefined(sizeSeriesLabel) && + !sizeIsValueMetric && + (name === sizeSeriesLabel || name.startsWith(`${sizeSeriesLabel}, `)); + const rawSeries = sizeSeriesLabel + ? allRawSeries.filter(entry => !isSizeSeries(String(entry.name ?? ''))) + : allRawSeries; + // Maps each value series' dimension key to a lookup from primary-axis value + // to size value. + let sizeLookups: Map> | undefined; + let sizeExtent: [number, number] | undefined; + if (sizeSeriesLabel) { + let sizeMin = Infinity; + let sizeMax = -Infinity; + if (sizeIsValueMetric) { + rawSeries.forEach(entry => { + (entry.data as DataRecordValue[][]).forEach(datum => { + const sizeValue = isHorizontal ? datum[0] : datum[1]; + if (typeof sizeValue === 'number' && Number.isFinite(sizeValue)) { + sizeMin = Math.min(sizeMin, sizeValue); + sizeMax = Math.max(sizeMax, sizeValue); + } + }); + }); + } else { + sizeLookups = new Map(); + allRawSeries + .filter(entry => isSizeSeries(String(entry.name ?? ''))) + .forEach(entry => { + const name = String(entry.name ?? ''); + const dimsKey = + name === sizeSeriesLabel + ? '' + : name.slice(sizeSeriesLabel.length + 2); + const lookup = new Map(); + (entry.data as DataRecordValue[][]).forEach(datum => { + const axisValue = isHorizontal ? datum[1] : datum[0]; + const sizeValue = isHorizontal ? datum[0] : datum[1]; + if (typeof sizeValue === 'number' && Number.isFinite(sizeValue)) { + lookup.set(axisValue, sizeValue); + sizeMin = Math.min(sizeMin, sizeValue); + sizeMax = Math.max(sizeMax, sizeValue); + } + }); + sizeLookups!.set(dimsKey, lookup); + }); + } + if (sizeMin <= sizeMax) { + sizeExtent = [sizeMin, sizeMax]; + } + } + // Strips the metric label off a series name, leaving the dimension key used + // to match a value series with its size series. + const getSeriesDimsKey = (name: string): string => { + const matchedLabel = valueMetricLabels.find( + label => name === label || name.startsWith(`${label}, `), + ); + if (matchedLabel === undefined) { + return name; + } + return name === matchedLabel ? '' : name.slice(matchedLabel.length + 2); + }; + // Normalize the configured dot size range so an inverted min/max still + // scales larger metric values to larger dots. + const markerSizeRange: [number, number] = + minMarkerSize <= maxMarkerSize + ? [minMarkerSize, maxMarkerSize] + : [maxMarkerSize, minMarkerSize]; + const showValueIndexes = extractShowValueIndexes(rawSeries, { stack, onlyTotal, @@ -472,6 +565,27 @@ export default function transformProps( } } + let symbolSizeFn: + | ((value: (number | string | null)[]) => number) + | undefined; + if (sizeExtent) { + const extent = sizeExtent; + const sizeLookup = sizeLookups?.get(getSeriesDimsKey(entryName)); + if (sizeIsValueMetric || sizeLookup) { + symbolSizeFn = value => { + const sizeValue = sizeIsValueMetric + ? value[isHorizontal ? 0 : 1] + : sizeLookup!.get(value[isHorizontal ? 1 : 0]); + // Points with a missing/invalid size value get the smallest + // configured dot size; the fixed marker size control is hidden + // when a size metric is set, so its value would be stale here. + return typeof sizeValue === 'number' + ? getAreaScaledSymbolSize(sizeValue, extent, markerSizeRange) + : markerSizeRange[0]; + }; + } + } + const transformedSeries = transformSeries( entry, colorScale, @@ -483,6 +597,7 @@ export default function transformProps( seriesContexts, markerEnabled, markerSize, + symbolSizeFn, areaOpacity: opacity, seriesType, legendState, 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 78c1222a1fc6..6a570b6bf4e2 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformers.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformers.ts @@ -205,6 +205,7 @@ export function transformSeries( seriesContexts?: { [key: string]: ForecastSeriesEnum[] }; markerEnabled?: boolean; markerSize?: number; + symbolSizeFn?: (value: (number | string | null)[]) => number; areaOpacity?: number; seriesType?: EchartsTimeseriesSeriesType; stack?: StackType; @@ -239,6 +240,7 @@ export function transformSeries( seriesContexts = {}, markerEnabled, markerSize, + symbolSizeFn, areaOpacity = 1, seriesType, stack, @@ -413,7 +415,7 @@ export function transformSeries( emphasis, showSymbol, symbol, - symbolSize: markerSize, + symbolSize: symbolSizeFn ?? markerSize, label: { show: !!showValue, position: isHorizontal ? 'right' : 'top', 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 a2051c0363d0..befc23839684 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/types.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/types.ts @@ -66,6 +66,8 @@ export type EchartsTimeseriesFormData = QueryFormData & { logAxis: boolean; markerEnabled: boolean; markerSize: number; + maxMarkerSize?: number; + minMarkerSize?: number; metrics: QueryFormMetric[]; minorSplitLine: boolean; minorTicks: boolean; @@ -73,6 +75,7 @@ export type EchartsTimeseriesFormData = QueryFormData & { orderDesc: boolean; rowLimit: number; seriesType: EchartsTimeseriesSeriesType; + size?: QueryFormMetric; stack: StackType; stackDimension: string; timeCompare?: string[]; 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 a8c80303ced0..c6657ee764c5 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/utils/series.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/utils/series.ts @@ -930,6 +930,36 @@ export function sanitizeHtml(text: string): string { return format.encodeHTML(text); } +/** + * Map a metric value to a marker diameter such that the marker's *area* + * (not its diameter) scales linearly with the value between the smallest + * and largest observed values. Area-based scaling avoids the perceptual + * exaggeration that diameter-linear scaling causes, where a 2x value + * renders as a 4x area. + * + * @param value - the metric value for this data point + * @param valueExtent - [min, max] of the metric across all data points + * @param sizeRange - [min, max] marker diameter in pixels + */ +export function getAreaScaledSymbolSize( + value: number, + valueExtent: [number, number], + sizeRange: [number, number], +): number { + const [minValue, maxValue] = valueExtent; + const [minSize, maxSize] = sizeRange; + if (!Number.isFinite(value) || maxValue === minValue) { + // single-valued or invalid data: use the diameter whose area is the + // midpoint of the configured area range + return Math.sqrt((minSize ** 2 + maxSize ** 2) / 2); + } + const ratio = Math.min( + Math.max((value - minValue) / (maxValue - minValue), 0), + 1, + ); + return Math.sqrt(minSize ** 2 + ratio * (maxSize ** 2 - minSize ** 2)); +} + export function getAxisType( stack: StackType, forceCategorical?: boolean, diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/Area/controlPanel.test.ts b/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/Area/controlPanel.test.ts index 20a36341694f..fa4aa969b65e 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/Area/controlPanel.test.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/Area/controlPanel.test.ts @@ -16,58 +16,14 @@ * specific language governing permissions and limitations * under the License. */ -import { ControlPanelsContainerProps } from '@superset-ui/chart-controls/types'; import { GenericDataType } from '@apache-superset/core/common'; import controlPanel from '../../../src/Timeseries/Area/controlPanel'; +import { getControl, mockControls } from '../helpers'; const config = controlPanel; -const getControl = (controlName: string) => { - for (const section of config.controlPanelSections) { - if (section && section.controlSetRows) { - for (const row of section.controlSetRows) { - for (const control of row) { - if ( - typeof control === 'object' && - control !== null && - 'name' in control && - control.name === controlName - ) { - return control; - } - } - } - } - } - - return null; -}; - -const mockControls = ( - xAxisColumn: string | null, - typeGeneric: GenericDataType | null, -): ControlPanelsContainerProps => { - const columns = - xAxisColumn && typeGeneric !== null - ? [{ column_name: xAxisColumn, type_generic: typeGeneric }] - : []; - - return { - controls: { - // @ts-expect-error - x_axis: { - value: xAxisColumn, - }, - // @ts-expect-error - datasource: { - datasource: { columns }, - }, - }, - }; -}; - -const timeFormatControl: any = getControl('x_axis_time_format'); -const numberFormatControl: any = getControl('x_axis_number_format'); +const timeFormatControl = getControl(config, 'x_axis_time_format')!; +const numberFormatControl = getControl(config, 'x_axis_number_format')!; test('should include x_axis_time_format control', () => { expect(timeFormatControl).toBeDefined(); diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/Line/controlPanel.test.ts b/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/Line/controlPanel.test.ts index 4183d1dba7e9..4cd1edc73ec4 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/Line/controlPanel.test.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/Line/controlPanel.test.ts @@ -16,58 +16,14 @@ * specific language governing permissions and limitations * under the License. */ -import { ControlPanelsContainerProps } from '@superset-ui/chart-controls/types'; import { GenericDataType } from '@apache-superset/core/common'; import controlPanel from '../../../src/Timeseries/Regular/Line/controlPanel'; +import { getControl, mockControls } from '../helpers'; const config = controlPanel; -const getControl = (controlName: string) => { - for (const section of config.controlPanelSections) { - if (section && section.controlSetRows) { - for (const row of section.controlSetRows) { - for (const control of row) { - if ( - typeof control === 'object' && - control !== null && - 'name' in control && - control.name === controlName - ) { - return control; - } - } - } - } - } - - return null; -}; - -const mockControls = ( - xAxisColumn: string | null, - typeGeneric: GenericDataType | null, -): ControlPanelsContainerProps => { - const columns = - xAxisColumn && typeGeneric !== null - ? [{ column_name: xAxisColumn, type_generic: typeGeneric }] - : []; - - return { - controls: { - // @ts-expect-error - x_axis: { - value: xAxisColumn, - }, - // @ts-expect-error - datasource: { - datasource: { columns }, - }, - }, - }; -}; - -const timeFormatControl: any = getControl('x_axis_time_format'); -const numberFormatControl: any = getControl('x_axis_number_format'); +const timeFormatControl = getControl(config, 'x_axis_time_format')!; +const numberFormatControl = getControl(config, 'x_axis_number_format')!; test('should include x_axis_time_format control', () => { expect(timeFormatControl).toBeDefined(); diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/Scatter/controlPanel.test.ts b/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/Scatter/controlPanel.test.ts index 943badfe02f6..1a27291fbdc4 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/Scatter/controlPanel.test.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/Scatter/controlPanel.test.ts @@ -19,55 +19,12 @@ import { ControlPanelsContainerProps } from '@superset-ui/chart-controls/types'; import { GenericDataType } from '@apache-superset/core/common'; import controlPanel from '../../../src/Timeseries/Regular/Scatter/controlPanel'; +import { getControl, mockControls } from '../helpers'; const config = controlPanel; -const getControl = (controlName: string) => { - for (const section of config.controlPanelSections) { - if (section && section.controlSetRows) { - for (const row of section.controlSetRows) { - for (const control of row) { - if ( - typeof control === 'object' && - control !== null && - 'name' in control && - control.name === controlName - ) { - return control; - } - } - } - } - } - - return null; -}; - -const mockControls = ( - xAxisColumn: string | null, - typeGeneric: GenericDataType | null, -): ControlPanelsContainerProps => { - const columns = - xAxisColumn && typeGeneric !== null - ? [{ column_name: xAxisColumn, type_generic: typeGeneric }] - : []; - - return { - controls: { - // @ts-expect-error - x_axis: { - value: xAxisColumn, - }, - // @ts-expect-error - datasource: { - datasource: { columns }, - }, - }, - }; -}; - // tests for x_axis_time_format control -const timeFormatControl: any = getControl('x_axis_time_format'); +const timeFormatControl = getControl(config, 'x_axis_time_format')!; test('scatter chart control panel should include x_axis_time_format control in the panel', () => { expect(timeFormatControl).toBeDefined(); @@ -108,7 +65,7 @@ test('x_axis_time_format control should be hidden for non-temporal data types', }); // tests for x_axis_number_format control -const numberFormatControl: any = getControl('x_axis_number_format'); +const numberFormatControl = getControl(config, 'x_axis_number_format')!; test('scatter chart control panel should include x_axis_number_format control in the panel', () => { expect(numberFormatControl).toBeDefined(); @@ -148,3 +105,59 @@ test('x_axis_number_format control should be hidden for non-numeric data types', expect(isNumberVisible(null, null)).toBe(false); expect(isNumberVisible('time_column', GenericDataType.Temporal)).toBe(false); }); + +// tests for orientation and dot size controls +const orientationControl = getControl(config, 'orientation')!; +const sizeControl = getControl(config, 'size')!; +const minMarkerSizeControl = getControl(config, 'minMarkerSize')!; +const maxMarkerSizeControl = getControl(config, 'maxMarkerSize')!; + +test('scatter chart control panel should include an orientation control defaulting to vertical', () => { + expect(orientationControl).toBeDefined(); + expect(orientationControl.config.default).toBe('vertical'); + expect(orientationControl.config.options).toEqual([ + ['vertical', expect.anything()], + ['horizontal', expect.anything()], + ]); +}); + +test('scatter chart control panel should include an optional dot size metric control', () => { + expect(sizeControl).toBeDefined(); + expect(sizeControl.config.validators).toEqual([]); + expect(sizeControl.config.default).toBeNull(); +}); + +const mockSizeControls = ( + sizeValue: string | null, +): ControlPanelsContainerProps => + ({ + controls: { + size: { value: sizeValue }, + markerEnabled: { value: true }, + }, + }) as unknown as ControlPanelsContainerProps; + +test('dot size range controls should only be visible when a size metric is set', () => { + expect(minMarkerSizeControl.config.visibility(mockSizeControls(null))).toBe( + false, + ); + expect(maxMarkerSizeControl.config.visibility(mockSizeControls(null))).toBe( + false, + ); + expect( + minMarkerSizeControl.config.visibility(mockSizeControls('size_metric')), + ).toBe(true); + expect( + maxMarkerSizeControl.config.visibility(mockSizeControls('size_metric')), + ).toBe(true); +}); + +test('fixed marker size control should hide when a size metric is set', () => { + const markerSizeControl = getControl(config, 'markerSize')!; + expect(markerSizeControl.config.visibility(mockSizeControls(null))).toBe( + true, + ); + expect( + markerSizeControl.config.visibility(mockSizeControls('size_metric')), + ).toBe(false); +}); diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/Scatter/transformProps.test.ts b/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/Scatter/transformProps.test.ts index 401f85ed5cf6..eecb580d82c8 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/Scatter/transformProps.test.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/Scatter/transformProps.test.ts @@ -32,6 +32,22 @@ import { } from '@superset-ui/chart-controls'; import { supersetTheme } from '@apache-superset/core/theme'; +type AxisLabelFormatter = ((value: number | string) => string) & { + id?: string; +}; + +interface TestAxis { + type?: string; + axisLabel: { formatter: AxisLabelFormatter }; +} + +interface TestScatterSeries { + type?: string; + name?: string; + data: (string | number | null)[][]; + symbolSize: (value: (string | number | null)[]) => number; +} + describe('Scatter Chart X-axis Time Formatting', () => { const baseFormData: EchartsTimeseriesFormData = { ...DEFAULT_FORM_DATA, @@ -74,7 +90,7 @@ describe('Scatter Chart X-axis Time Formatting', () => { ); expect(transformedProps.echartOptions.xAxis).toHaveProperty('axisLabel'); - const xAxis = transformedProps.echartOptions.xAxis as any; + const xAxis = transformedProps.echartOptions.xAxis as TestAxis; expect(xAxis.axisLabel).toHaveProperty('formatter'); expect(typeof xAxis.axisLabel.formatter).toBe('function'); }); @@ -94,7 +110,7 @@ describe('Scatter Chart X-axis Time Formatting', () => { chartProps as EchartsTimeseriesChartProps, ); - const xAxis = transformedProps.echartOptions.xAxis as any; + const xAxis = transformedProps.echartOptions.xAxis as TestAxis; expect(xAxis.axisLabel).toHaveProperty('formatter'); expect(typeof xAxis.axisLabel.formatter).toBe('function'); if (format !== SMART_DATE_ID) { @@ -146,7 +162,7 @@ describe('Scatter Chart X-axis Number Formatting', () => { ); expect(transformedProps.echartOptions.xAxis).toHaveProperty('axisLabel'); - const xAxis = transformedProps.echartOptions.xAxis as any; + const xAxis = transformedProps.echartOptions.xAxis as TestAxis; expect(xAxis.axisLabel).toHaveProperty('formatter'); expect(typeof xAxis.axisLabel.formatter).toBe('function'); expect(xAxis.axisLabel.formatter.id).toBe('SMART_NUMBER'); @@ -168,10 +184,324 @@ describe('Scatter Chart X-axis Number Formatting', () => { ); expect(transformedProps.echartOptions.xAxis).toHaveProperty('axisLabel'); - const xAxis = transformedProps.echartOptions.xAxis as any; + const xAxis = transformedProps.echartOptions.xAxis as TestAxis; expect(xAxis.axisLabel).toHaveProperty('formatter'); expect(typeof xAxis.axisLabel.formatter).toBe('function'); expect(xAxis.axisLabel.formatter.id).toBe(format); }, ); }); + +describe('Scatter Chart Orientation and Dot Size Metric', () => { + const baseFormData: EchartsTimeseriesFormData = { + ...DEFAULT_FORM_DATA, + colorScheme: 'supersetColors', + datasource: '1__table', + metrics: ['sum_val'], + x_axis: 'category_col', + groupby: [], + viz_type: 'echarts_timeseries_scatter', + seriesType: EchartsTimeseriesSeriesType.Scatter, + }; + + const categoricalData = [ + { + data: [ + { category_col: 'A', sum_val: 1, size_metric: 10 }, + { category_col: 'B', sum_val: 2, size_metric: 25 }, + { category_col: 'C', sum_val: 3, size_metric: 40 }, + ], + colnames: ['category_col', 'sum_val', 'size_metric'], + coltypes: [ + GenericDataType.String, + GenericDataType.Numeric, + GenericDataType.Numeric, + ], + label_map: { + category_col: ['category_col'], + sum_val: ['sum_val'], + size_metric: ['size_metric'], + }, + }, + ]; + + const baseChartPropsConfig = { + width: 800, + height: 600, + theme: supersetTheme, + }; + + const getScatterSeries = (props: ReturnType) => + (props.echartOptions.series as TestScatterSeries[]).filter( + s => s.type === 'scatter', + ); + + const singleMetricData = [ + { + ...categoricalData[0], + data: categoricalData[0].data.map(row => ({ + category_col: row.category_col, + sum_val: row.sum_val, + })), + colnames: ['category_col', 'sum_val'], + coltypes: [GenericDataType.String, GenericDataType.Numeric], + }, + ]; + + test('horizontal orientation swaps the dimension axis onto the y-axis', () => { + const chartProps = new ChartProps({ + ...baseChartPropsConfig, + queriesData: singleMetricData, + formData: { ...baseFormData, orientation: 'horizontal' }, + }); + + const transformedProps = transformProps( + chartProps as EchartsTimeseriesChartProps, + ); + const { xAxis, yAxis } = transformedProps.echartOptions as { + xAxis: TestAxis; + yAxis: TestAxis; + }; + expect(yAxis.type).toBe('category'); + expect(xAxis.type).toBe('value'); + + const series = getScatterSeries(transformedProps); + expect(series).toHaveLength(1); + // data points are flipped to [metric, dimension] + expect(series[0].data[0]).toEqual([1, 'A']); + }); + + test('vertical orientation keeps the dimension axis on the x-axis', () => { + const chartProps = new ChartProps({ + ...baseChartPropsConfig, + queriesData: singleMetricData, + formData: baseFormData, + }); + + const transformedProps = transformProps( + chartProps as EchartsTimeseriesChartProps, + ); + const { xAxis, yAxis } = transformedProps.echartOptions as { + xAxis: TestAxis; + yAxis: TestAxis; + }; + expect(xAxis.type).toBe('category'); + expect(yAxis.type).toBe('value'); + + const series = getScatterSeries(transformedProps); + expect(series[0].data[0]).toEqual(['A', 1]); + }); + + test('size metric series is not rendered or shown in the legend', () => { + const chartProps = new ChartProps({ + ...baseChartPropsConfig, + queriesData: categoricalData, + formData: { ...baseFormData, size: 'size_metric' }, + }); + + const transformedProps = transformProps( + chartProps as EchartsTimeseriesChartProps, + ); + const series = getScatterSeries(transformedProps); + expect(series).toHaveLength(1); + expect(series[0].name).toBe('sum_val'); + expect(transformedProps.legendData).toEqual(['sum_val']); + }); + + test('size metric scales marker areas between min and max dot size', () => { + const chartProps = new ChartProps({ + ...baseChartPropsConfig, + queriesData: categoricalData, + formData: { + ...baseFormData, + size: 'size_metric', + minMarkerSize: 5, + maxMarkerSize: 30, + }, + }); + + const transformedProps = transformProps( + chartProps as EchartsTimeseriesChartProps, + ); + const [series] = getScatterSeries(transformedProps); + expect(typeof series.symbolSize).toBe('function'); + // smallest size value -> min dot size, largest -> max dot size + expect(series.symbolSize(['A', 1])).toBe(5); + expect(series.symbolSize(['C', 3])).toBe(30); + // midpoint value -> midpoint *area*, not midpoint diameter + expect(series.symbolSize(['B', 2]) ** 2).toBeCloseTo( + (5 ** 2 + 30 ** 2) / 2, + ); + }); + + test('size metric lookups follow the dimension key when grouped', () => { + const groupedData = [ + { + data: [ + { + category_col: 'A', + 'sum_val, g1': 1, + 'size_metric, g1': 10, + 'sum_val, g2': 2, + 'size_metric, g2': 40, + }, + ], + colnames: [ + 'category_col', + 'sum_val, g1', + 'size_metric, g1', + 'sum_val, g2', + 'size_metric, g2', + ], + coltypes: [ + GenericDataType.String, + GenericDataType.Numeric, + GenericDataType.Numeric, + GenericDataType.Numeric, + GenericDataType.Numeric, + ], + label_map: { + category_col: ['category_col'], + 'sum_val, g1': ['sum_val', 'g1'], + 'size_metric, g1': ['size_metric', 'g1'], + 'sum_val, g2': ['sum_val', 'g2'], + 'size_metric, g2': ['size_metric', 'g2'], + }, + }, + ]; + const chartProps = new ChartProps({ + ...baseChartPropsConfig, + queriesData: groupedData, + formData: { + ...baseFormData, + groupby: ['group_col'], + size: 'size_metric', + minMarkerSize: 5, + maxMarkerSize: 30, + }, + }); + + const transformedProps = transformProps( + chartProps as EchartsTimeseriesChartProps, + ); + const series = getScatterSeries(transformedProps); + expect(series.map(s => s.name).sort()).toEqual([ + 'sum_val, g1', + 'sum_val, g2', + ]); + const g1 = series.find(s => s.name === 'sum_val, g1')!; + const g2 = series.find(s => s.name === 'sum_val, g2')!; + // the size extent is global: g1's point holds the minimum (10), g2's the + // maximum (40) + expect(g1.symbolSize(['A', 1])).toBe(5); + expect(g2.symbolSize(['A', 2])).toBe(30); + }); + + test('horizontal orientation and size metric compose', () => { + const chartProps = new ChartProps({ + ...baseChartPropsConfig, + queriesData: categoricalData, + formData: { + ...baseFormData, + orientation: 'horizontal', + size: 'size_metric', + minMarkerSize: 5, + maxMarkerSize: 30, + }, + }); + + const transformedProps = transformProps( + chartProps as EchartsTimeseriesChartProps, + ); + const [series] = getScatterSeries(transformedProps); + expect(series.data[0]).toEqual([1, 'A']); + // with flipped data, the dimension value is at index 1 + expect(series.symbolSize([1, 'A'])).toBe(5); + expect(series.symbolSize([3, 'C'])).toBe(30); + }); + + test('points without a size value fall back to the minimum dot size', () => { + const chartProps = new ChartProps({ + ...baseChartPropsConfig, + queriesData: [ + { + ...categoricalData[0], + data: [ + { category_col: 'A', sum_val: 1, size_metric: 10 }, + { category_col: 'B', sum_val: 2, size_metric: null }, + { category_col: 'C', sum_val: 3, size_metric: 40 }, + ], + }, + ], + formData: { + ...baseFormData, + size: 'size_metric', + minMarkerSize: 9, + maxMarkerSize: 30, + }, + }); + + const transformedProps = transformProps( + chartProps as EchartsTimeseriesChartProps, + ); + const [series] = getScatterSeries(transformedProps); + expect(series.symbolSize(['B', 2])).toBe(9); + }); + + test('an inverted min/max dot size range is normalized', () => { + const chartProps = new ChartProps({ + ...baseChartPropsConfig, + queriesData: categoricalData, + formData: { + ...baseFormData, + size: 'size_metric', + minMarkerSize: 30, + maxMarkerSize: 5, + }, + }); + + const transformedProps = transformProps( + chartProps as EchartsTimeseriesChartProps, + ); + const [series] = getScatterSeries(transformedProps); + // larger metric values still render as larger dots + expect(series.symbolSize(['A', 1])).toBe(5); + expect(series.symbolSize(['C', 3])).toBe(30); + }); + + test('size metric equal to the value metric sizes dots by their own value', () => { + const dedupedData = [ + { + data: [ + { category_col: 'A', sum_val: 1 }, + { category_col: 'B', sum_val: 2 }, + { category_col: 'C', sum_val: 3 }, + ], + colnames: ['category_col', 'sum_val'], + coltypes: [GenericDataType.String, GenericDataType.Numeric], + label_map: { + category_col: ['category_col'], + sum_val: ['sum_val'], + }, + }, + ]; + const chartProps = new ChartProps({ + ...baseChartPropsConfig, + queriesData: dedupedData, + formData: { + ...baseFormData, + size: 'sum_val', + minMarkerSize: 5, + maxMarkerSize: 30, + }, + }); + + const transformedProps = transformProps( + chartProps as EchartsTimeseriesChartProps, + ); + const series = getScatterSeries(transformedProps); + expect(series).toHaveLength(1); + expect(series[0].symbolSize(['A', 1])).toBe(5); + expect(series[0].symbolSize(['C', 3])).toBe(30); + }); +}); diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/SmoothLine/controlPanel.test.ts b/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/SmoothLine/controlPanel.test.ts index 1c1a634db3d2..b26cb5ee3655 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/SmoothLine/controlPanel.test.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/SmoothLine/controlPanel.test.ts @@ -16,58 +16,14 @@ * specific language governing permissions and limitations * under the License. */ -import { ControlPanelsContainerProps } from '@superset-ui/chart-controls/types'; import { GenericDataType } from '@apache-superset/core/common'; import controlPanel from '../../../src/Timeseries/Regular/SmoothLine/controlPanel'; +import { getControl, mockControls } from '../helpers'; const config = controlPanel; -const getControl = (controlName: string) => { - for (const section of config.controlPanelSections) { - if (section && section.controlSetRows) { - for (const row of section.controlSetRows) { - for (const control of row) { - if ( - typeof control === 'object' && - control !== null && - 'name' in control && - control.name === controlName - ) { - return control; - } - } - } - } - } - - return null; -}; - -const mockControls = ( - xAxisColumn: string | null, - typeGeneric: GenericDataType | null, -): ControlPanelsContainerProps => { - const columns = - xAxisColumn && typeGeneric !== null - ? [{ column_name: xAxisColumn, type_generic: typeGeneric }] - : []; - - return { - controls: { - // @ts-expect-error - x_axis: { - value: xAxisColumn, - }, - // @ts-expect-error - datasource: { - datasource: { columns }, - }, - }, - }; -}; - -const timeFormatControl: any = getControl('x_axis_time_format'); -const numberFormatControl: any = getControl('x_axis_number_format'); +const timeFormatControl = getControl(config, 'x_axis_time_format')!; +const numberFormatControl = getControl(config, 'x_axis_number_format')!; test('should include x_axis_time_format control', () => { expect(timeFormatControl).toBeDefined(); diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/Step/controlPanel.test.ts b/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/Step/controlPanel.test.ts index dcd36ebacd29..e4eaaa65b350 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/Step/controlPanel.test.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/Step/controlPanel.test.ts @@ -16,58 +16,14 @@ * specific language governing permissions and limitations * under the License. */ -import { ControlPanelsContainerProps } from '@superset-ui/chart-controls/types'; import { GenericDataType } from '@apache-superset/core/common'; import controlPanel from '../../../src/Timeseries/Step/controlPanel'; +import { getControl, mockControls } from '../helpers'; const config = controlPanel; -const getControl = (controlName: string) => { - for (const section of config.controlPanelSections) { - if (section && section.controlSetRows) { - for (const row of section.controlSetRows) { - for (const control of row) { - if ( - typeof control === 'object' && - control !== null && - 'name' in control && - control.name === controlName - ) { - return control; - } - } - } - } - } - - return null; -}; - -const mockControls = ( - xAxisColumn: string | null, - typeGeneric: GenericDataType | null, -): ControlPanelsContainerProps => { - const columns = - xAxisColumn && typeGeneric !== null - ? [{ column_name: xAxisColumn, type_generic: typeGeneric }] - : []; - - return { - controls: { - // @ts-expect-error - x_axis: { - value: xAxisColumn, - }, - // @ts-expect-error - datasource: { - datasource: { columns }, - }, - }, - }; -}; - -const timeFormatControl: any = getControl('x_axis_time_format'); -const numberFormatControl: any = getControl('x_axis_number_format'); +const timeFormatControl = getControl(config, 'x_axis_time_format')!; +const numberFormatControl = getControl(config, 'x_axis_number_format')!; test('should include x_axis_time_format control', () => { expect(timeFormatControl).toBeDefined(); diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/buildQuery.test.ts b/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/buildQuery.test.ts index 8caef0d58fe5..1bc3b8c50f24 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/buildQuery.test.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/buildQuery.test.ts @@ -46,6 +46,24 @@ describe('Timeseries buildQuery', () => { expect(query.orderby).toEqual([['bar', false]]); }); + test('should include the scatter dot size metric in query metrics', () => { + const queryContext = buildQuery({ + ...formData, + size: 'qux', + }); + const [query] = queryContext.queries; + expect(query.metrics).toEqual(['bar', 'baz', 'qux']); + }); + + test('should dedupe the dot size metric when it is also a value metric', () => { + const queryContext = buildQuery({ + ...formData, + size: 'bar', + }); + const [query] = queryContext.queries; + expect(query.metrics).toEqual(['bar', 'baz']); + }); + test('should not order by timeseries limit if orderby provided', () => { const queryContext = buildQuery({ ...formData, diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/helpers.ts b/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/helpers.ts index b25476dd8520..2068df3f7c05 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/helpers.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/helpers.ts @@ -16,6 +16,11 @@ * specific language governing permissions and limitations * under the License. */ +import type { + ControlPanelConfig, + ControlPanelsContainerProps, +} from '@superset-ui/chart-controls/types'; +import type { GenericDataType } from '@apache-superset/core/common'; /** * Base timestamp used for test data generation. @@ -120,3 +125,80 @@ export function createTestData( createTestDataRow(row, baseTimestamp + index * intervalMs), ); } + +/** + * Narrow view of a named control entry covering the config fields control + * panel tests assert on, so lookups stay type-safe without resorting to + * `any`. + */ +export interface TestControl { + name: string; + config: { + default?: unknown; + options?: unknown; + validators?: unknown; + visibility: (props: ControlPanelsContainerProps) => boolean; + }; +} + +/** + * Finds a named control entry in a control panel config. + * + * @param config - Control panel config to search + * @param controlName - Name of the control to look up + * @returns The matching control entry, or null when absent + */ +export function getControl( + config: ControlPanelConfig, + controlName: string, +): TestControl | null { + for (const section of config.controlPanelSections) { + if (section?.controlSetRows) { + for (const row of section.controlSetRows) { + for (const control of row) { + if ( + typeof control === 'object' && + control !== null && + 'name' in control && + control.name === controlName + ) { + return control as unknown as TestControl; + } + } + } + } + } + + return null; +} + +/** + * Builds a minimal ControlPanelsContainerProps mock with an x-axis column of + * the given generic type, for exercising control visibility functions. + * + * @param xAxisColumn - Name of the x-axis column, or null for none + * @param typeGeneric - Generic data type of the column, or null for none + * @returns Mocked control panel container props + */ +export function mockControls( + xAxisColumn: string | null, + typeGeneric: GenericDataType | null, +): ControlPanelsContainerProps { + const columns = + xAxisColumn && typeGeneric !== null + ? [{ column_name: xAxisColumn, type_generic: typeGeneric }] + : []; + + return { + controls: { + // @ts-expect-error + x_axis: { + value: xAxisColumn, + }, + // @ts-expect-error + datasource: { + datasource: { columns }, + }, + }, + }; +} diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/transformers.test.ts b/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/transformers.test.ts index 19aa1d4a0d62..2307da23e043 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/transformers.test.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/transformers.test.ts @@ -24,6 +24,7 @@ import { import { GenericDataType } from '@apache-superset/core/common'; import { supersetTheme } from '@apache-superset/core/theme'; import type { SeriesOption } from 'echarts'; +import type { ScatterSeriesOption } from 'echarts/charts'; import { EchartsTimeseriesSeriesType } from '../../src'; import { TIMESERIES_CONSTANTS } from '../../src/constants'; import { LegendOrientation } from '../../src/types'; @@ -125,6 +126,36 @@ describe('transformSeries', () => { // OpacityEnum.NonTransparent = 1 (not dimmed) expect((result as any).itemStyle.opacity).toBe(1); }); + + test('should use symbolSizeFn for symbolSize when provided', () => { + const symbolSizeFn = jest.fn( + (value: (number | string | null)[]) => Number(value[1]) * 2, + ); + const opts = { + seriesType: EchartsTimeseriesSeriesType.Scatter, + markerSize: 7, + symbolSizeFn, + timeShiftColor: false, + }; + + const result = transformSeries(series, mockColorScale, 'test-key', opts); + + const { symbolSize } = result as ScatterSeriesOption; + expect(symbolSize).toBe(symbolSizeFn); + expect(symbolSizeFn(['A', 4])).toBe(8); + }); + + test('should fall back to markerSize for symbolSize when symbolSizeFn is not provided', () => { + const opts = { + seriesType: EchartsTimeseriesSeriesType.Scatter, + markerSize: 7, + timeShiftColor: false, + }; + + const result = transformSeries(series, mockColorScale, 'test-key', opts); + + expect((result as ScatterSeriesOption).symbolSize).toBe(7); + }); }); describe('transformNegativeLabelsPosition', () => { diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/utils/series.test.ts b/superset-frontend/plugins/plugin-chart-echarts/test/utils/series.test.ts index 0817e2896b28..95a778e0b4fa 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/test/utils/series.test.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/test/utils/series.test.ts @@ -33,6 +33,7 @@ import { extractShowValueIndexes, extractTooltipKeys, formatSeriesName, + getAreaScaledSymbolSize, getAxisType, getChartPadding, getLegendProps, @@ -1598,3 +1599,29 @@ test('extractTooltipKeys with non-rich tooltip', () => { const result = extractTooltipKeys(forecastValue, 1, false, false); expect(result).toEqual(['foo']); }); + +test('getAreaScaledSymbolSize maps the value extent to the size range', () => { + // smallest value renders at the minimum diameter + expect(getAreaScaledSymbolSize(10, [10, 40], [5, 30])).toBe(5); + // largest value renders at the maximum diameter + expect(getAreaScaledSymbolSize(40, [10, 40], [5, 30])).toBe(30); +}); + +test('getAreaScaledSymbolSize scales area, not diameter', () => { + // the midpoint value's *area* is halfway between the min and max areas + const midSize = getAreaScaledSymbolSize(25, [10, 40], [5, 30]); + expect(midSize ** 2).toBeCloseTo((5 ** 2 + 30 ** 2) / 2); +}); + +test('getAreaScaledSymbolSize clamps values outside the extent', () => { + expect(getAreaScaledSymbolSize(-100, [10, 40], [5, 30])).toBe(5); + expect(getAreaScaledSymbolSize(1000, [10, 40], [5, 30])).toBe(30); +}); + +test('getAreaScaledSymbolSize handles degenerate extents and bad values', () => { + const midAreaSize = Math.sqrt((5 ** 2 + 30 ** 2) / 2); + expect(getAreaScaledSymbolSize(7, [7, 7], [5, 30])).toBeCloseTo(midAreaSize); + expect(getAreaScaledSymbolSize(NaN, [10, 40], [5, 30])).toBeCloseTo( + midAreaSize, + ); +});