Compare commits

..

1 Commits

Author SHA1 Message Date
Superset Dev
1fbde07e30 feat(scatter): add chart orientation and dot size metric controls
Adds two capabilities to the ECharts Scatter chart
(echarts_timeseries_scatter):

- A Vertical/Horizontal orientation control, reusing the shared
  Timeseries horizontal-orientation plumbing built for the Bar chart.
  Horizontal orientation places the dimension on the y-axis, enabling
  categorical (or time/numeric) y-axes with the metric on the x-axis.
  Axis-related controls swap between the X/Y sections based on
  orientation, mirroring the Bar chart panel.
- An optional "Dot size metric" with minimum/maximum dot size sliders.
  The size metric rides along in the query (deduped if it matches a
  value metric), its series are excluded from rendering, and each
  point's marker is sized so that marker *area* scales linearly with
  the metric between the configured bounds, avoiding the perceptual
  exaggeration of diameter-linear scaling.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 00:48:36 -07:00
11 changed files with 902 additions and 107 deletions

View File

@@ -63,7 +63,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -74,6 +74,6 @@ jobs:
# queries: security-extended,security-and-quality
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
with:
category: "/language:${{matrix.language}}"

View File

@@ -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: [
[<ControlSubSectionHeader>{t('X Axis')}</ControlSubSectionHeader>],
...createAxisTitleControl('x'),
[<ControlSubSectionHeader>{t('Y Axis')}</ControlSubSectionHeader>],
...createAxisTitleControl('y'),
],
},
{
label: t('Chart Options'),
expanded: true,
@@ -99,7 +428,42 @@ 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,
},
},
],
[
{
name: 'minMarkerSize',
config: {
type: 'SliderControl',
label: t('Minimum dot size'),
renderTrigger: true,
min: 1,
max: 100,
default: minMarkerSize,
description: t(
'Size of the dot representing the smallest value of the dot size metric.',
),
visibility: ({ controls }: ControlPanelsContainerProps) =>
Boolean(controls?.size?.value),
},
},
{
name: 'maxMarkerSize',
config: {
type: 'SliderControl',
label: t('Maximum dot size'),
renderTrigger: true,
min: 1,
max: 100,
default: maxMarkerSize,
description: t(
'Size of the dot representing the largest value of the dot size metric.',
),
visibility: ({ controls }: ControlPanelsContainerProps) =>
Boolean(controls?.size?.value),
},
},
],
@@ -107,107 +471,13 @@ const config: ControlPanelConfig = {
[minorTicks],
...legendSection,
[<ControlSubSectionHeader>{t('X Axis')}</ControlSubSectionHeader>],
[
{
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',
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
[<ControlSubSectionHeader>{t('Y Axis')}</ControlSubSectionHeader>],
['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'),
},
},
],
...createAxisControl('x'),
[truncateXAxis],
[xAxisBounds],
[
{
name: 'truncateYAxis',
config: {
type: 'CheckboxControl',
label: t('Truncate Y Axis'),
default: truncateYAxis,
renderTrigger: true,
description: t(
'Truncate Y Axis. Can be overridden by specifying a min or max bound.',
),
},
},
],
[
{
name: 'y_axis_bounds',
config: {
type: 'BoundsControl',
label: t('Y Axis Bounds'),
renderTrigger: true,
default: yAxisBounds,
description: t(
'Bounds for the Y-axis. When left empty, the bounds are ' +
'dynamically defined based on the min/max of the data. Note that ' +
"this feature will only expand the axis range. It won't " +
"narrow the data's extent.",
),
visibility: ({ controls }: ControlPanelsContainerProps) =>
Boolean(controls?.truncateYAxis?.value),
},
},
],
[forceMaxInterval],
...richTooltipSection,
[<ControlSubSectionHeader>{t('Y Axis')}</ControlSubSectionHeader>],
...createAxisControl('y'),
],
},
],

View File

@@ -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,

View File

@@ -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,89 @@ 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<string, Map<DataRecordValue, number>> | 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<DataRecordValue, number>();
(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);
};
const markerSizeRange: [number, number] = [minMarkerSize, maxMarkerSize];
const showValueIndexes = extractShowValueIndexes(rawSeries, {
stack,
onlyTotal,
@@ -472,6 +560,24 @@ 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]);
return typeof sizeValue === 'number'
? getAreaScaledSymbolSize(sizeValue, extent, markerSizeRange)
: markerSize;
};
}
}
const transformedSeries = transformSeries(
entry,
colorScale,
@@ -483,6 +589,7 @@ export default function transformProps(
seriesContexts,
markerEnabled,
markerSize,
symbolSizeFn,
areaOpacity: opacity,
seriesType,
legendState,

View File

@@ -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',

View File

@@ -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[];

View File

@@ -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,

View File

@@ -24,7 +24,7 @@ const config = controlPanel;
const getControl = (controlName: string) => {
for (const section of config.controlPanelSections) {
if (section && section.controlSetRows) {
if (section?.controlSetRows) {
for (const row of section.controlSetRows) {
for (const control of row) {
if (
@@ -148,3 +148,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: any = getControl('orientation');
const sizeControl: any = getControl('size');
const minMarkerSizeControl: any = getControl('minMarkerSize');
const maxMarkerSizeControl: any = getControl('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: any = getControl('markerSize');
expect(markerSizeControl.config.visibility(mockSizeControls(null))).toBe(
true,
);
expect(
markerSizeControl.config.visibility(mockSizeControls('size_metric')),
).toBe(false);
});

View File

@@ -175,3 +175,283 @@ describe('Scatter Chart X-axis Number Formatting', () => {
},
);
});
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<typeof transformProps>) =>
(props.echartOptions.series as any[]).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 any;
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 any;
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: any) => s.name).sort()).toEqual([
'sum_val, g1',
'sum_val, g2',
]);
const g1 = series.find((s: any) => s.name === 'sum_val, g1');
const g2 = series.find((s: any) => 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 fixed marker 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', markerSize: 7 },
});
const transformedProps = transformProps(
chartProps as EchartsTimeseriesChartProps,
);
const [series] = getScatterSeries(transformedProps);
expect(series.symbolSize(['B', 2])).toBe(7);
});
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);
});
});

View File

@@ -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,

View File

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