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,
+ );
+});