Compare commits

...

9 Commits

Author SHA1 Message Date
Evan
8a82191af1 fix(scatter): scope currency format to metric axis, fix log-axis bound with size metric
The X/Y axis currency format control rendered under both axis sections
unconditionally; gate it to the metric axis like the other axis controls.

Recompute the log-axis lower bound from the series that actually render
instead of the pre-filter series list, so a hidden dot-size metric no
longer skews the axis when it has a different value range than the
metric being plotted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 13:39:20 -07:00
Evan Rusackas
3ceaa54341 style(scatter): apply prettier 3.9.5 formatting
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 10:14:47 -07:00
Claude Code
372802b313 Merge remote-tracking branch 'origin/master' into pr-40967 2026-07-21 09:52:13 -07:00
Evan Rusackas
039fbb245c fix(scatter): annotate time-compare test formData to satisfy typecheck
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 09:26:55 -07:00
Evan Rusackas
7f1c3f92b0 fix(scatter): classify time-comparison series in dot-size-by-metric
Time-comparison series keep the raw metric label with an __<offset>
suffix, which the size-series classification had no branch for: the size
metric's offset series leaked into the rendered chart and legend, and
comparison value series missed their size lookups. Match both the
verbose base label and raw __<offset> variants, and key size lookups by
offset so each comparison series is sized from its own offset's values.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 09:01:21 -07:00
Evan
01569a90fa test(scatter): replace any casts with ScatterSeriesOption in symbolSize assertions
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 14:54:31 -07:00
Evan
c279ae4e83 test(scatter): cover symbolSizeFn fallback, share control panel test helpers
- Add transformSeries unit tests for the symbolSizeFn / markerSize fallback
- Extract duplicated getControl/mockControls helpers from five Timeseries
  controlPanel test files into the shared test helpers module

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 14:54:31 -07:00
Evan
f79ce4ee82 fix(scatter): normalize dot size range, fall back to min dot size, drop any types in tests
- normalize an inverted min/max dot size range so larger values always
  render as larger dots
- fall back to the minimum configured dot size (not the hidden fixed
  marker size control) for points missing a size value
- replace any annotations/casts in scatter control panel and
  transformProps tests with concrete test types

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 14:54:31 -07:00
Superset Dev
4dba1c8409 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-23 14:54:30 -07:00
16 changed files with 1226 additions and 343 deletions

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,353 @@ 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,
},
},
],
[
{
name: 'currency_format',
config: {
...sharedControls.currency_format,
visibility: ({ controls }: ControlPanelsContainerProps) =>
showsMetricAxis(controls),
disableStash: true,
resetOnHide: false,
},
},
],
[
{
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 +439,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,106 +482,13 @@ const config: ControlPanelConfig = {
[minorTicks],
...legendSection,
[<ControlSubSectionHeader>{t('X Axis')}</ControlSubSectionHeader>],
[
{
name: 'x_axis_time_format',
config: {
...sharedControls.x_axis_time_format,
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,144 @@ 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;
// Time-comparison series keep the raw metric label with an `__<offset>`
// suffix (verbose mapping only applies to base columns, see
// rebaseForecastDatum), so a metric's series can be named `<label>`,
// `<label>, <dims>`, `<raw label>__<offset>` or
// `<raw label>__<offset>, <dims>`. Given a raw metric label, return the
// `<offset>|<dims>` key for a series name belonging to that metric, or
// undefined if the name doesn't belong to it. Keying by offset pairs each
// comparison value series with the size series from the same offset.
const timeCompareOffsets = ensureIsArray(timeCompare).map(String);
const matchSeriesKey = (
name: string,
rawLabel: string,
): string | undefined => {
const candidates = [
{ label: verboseMap[rawLabel] ?? rawLabel, offset: '' },
...timeCompareOffsets.map(offset => ({
label: `${rawLabel}__${offset}`,
offset,
})),
];
for (const { label, offset } of candidates) {
if (name === label) {
return `${offset}|`;
}
if (name.startsWith(`${label}, `)) {
return `${offset}|${name.slice(label.length + 2)}`;
}
}
return undefined;
};
const isSizeSeries = (name: string) =>
isDefined(sizeMetricLabel) &&
!sizeIsValueMetric &&
matchSeriesKey(name, sizeMetricLabel!) !== undefined;
const rawSeries = sizeSeriesLabel
? allRawSeries.filter(entry => !isSizeSeries(String(entry.name ?? '')))
: allRawSeries;
// A hidden size-only series carries its own value range (e.g. a revenue
// metric driving dot size while a different metric renders on the value
// axis), so recompute the lower bound used for log-axis ticks from the
// series that actually render rather than the pre-filter allRawSeries.
const renderedMinPositiveValue =
rawSeries === allRawSeries
? minPositiveValue
: rawSeries.reduce<number | undefined>((min, entry) => {
(
entry.data as [DataRecordValue, DataRecordValue][] | undefined
)?.forEach(datum => {
const value = isHorizontal ? datum[0] : datum[1];
if (
typeof value === 'number' &&
value > 0 &&
(min === undefined || value < min)
) {
min = value;
}
});
return min;
}, undefined);
// 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 = matchSeriesKey(name, sizeMetricLabel!)!;
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 `<offset>|<dims>`
// key used to match a value series with its size series.
const rawValueMetricLabels = ensureIsArray(metrics).map(getMetricLabel);
const getSeriesDimsKey = (name: string): string => {
for (const rawLabel of rawValueMetricLabels) {
const key = matchSeriesKey(name, rawLabel);
if (key !== undefined) {
return key;
}
}
return `|${name}`;
};
// 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,
@@ -481,6 +624,26 @@ 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,
@@ -492,6 +655,7 @@ export default function transformProps(
seriesContexts,
markerEnabled,
markerSize,
symbolSizeFn,
areaOpacity: opacity,
seriesType,
legendState,
@@ -692,9 +856,9 @@ export default function transformProps(
} else if (
logAxis &&
yAxisMin === undefined &&
minPositiveValue !== undefined
renderedMinPositiveValue !== undefined
) {
yAxisMin = calculateLowerLogTick(minPositiveValue);
yAxisMin = calculateLowerLogTick(renderedMinPositiveValue);
}
// For horizontal bar charts, set max/min from calculated data bounds

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

@@ -926,6 +926,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

@@ -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();

View File

@@ -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();

View File

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

View File

@@ -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,399 @@ 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<typeof transformProps>) =>
(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('size metric composes with time comparison offsets', () => {
// Time-comparison series are named `<metric>__<offset>`; the size
// metric's offset series must be excluded from rendering like its base
// series, and each comparison value series must be sized from the size
// series of the same offset.
const timeCompareData = [
{
data: [
{
category_col: 'A',
sum_val: 1,
size_metric: 10,
'sum_val__1 week ago': 3,
'size_metric__1 week ago': 40,
},
{
category_col: 'B',
sum_val: 2,
size_metric: 20,
'sum_val__1 week ago': 4,
'size_metric__1 week ago': 30,
},
],
colnames: [
'category_col',
'sum_val',
'size_metric',
'sum_val__1 week ago',
'size_metric__1 week ago',
],
coltypes: [
GenericDataType.String,
GenericDataType.Numeric,
GenericDataType.Numeric,
GenericDataType.Numeric,
GenericDataType.Numeric,
],
label_map: {
category_col: ['category_col'],
sum_val: ['sum_val'],
size_metric: ['size_metric'],
'sum_val__1 week ago': ['1 week ago', 'sum_val'],
'size_metric__1 week ago': ['1 week ago', 'size_metric'],
},
},
];
const chartProps = new ChartProps({
...baseChartPropsConfig,
queriesData: timeCompareData,
formData: {
...baseFormData,
size: 'size_metric',
minMarkerSize: 5,
maxMarkerSize: 30,
time_compare: ['1 week ago'],
} as EchartsTimeseriesFormData,
});
const transformedProps = transformProps(
chartProps as EchartsTimeseriesChartProps,
);
const series = getScatterSeries(transformedProps);
// Neither the size metric's base series nor its offset series renders.
expect(series.map(s => s.name).sort()).toEqual([
'sum_val',
'sum_val__1 week ago',
]);
const base = series.find(s => s.name === 'sum_val')!;
const offset = series.find(s => s.name === 'sum_val__1 week ago')!;
// The size extent is global across offsets: the base series' point A
// holds the minimum (10), the offset series' point A the maximum (40).
expect(base.symbolSize(['A', 1])).toBe(5);
expect(offset.symbolSize(['A', 3])).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);
});
});

View File

@@ -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();

View File

@@ -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();

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

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

View File

@@ -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 {
@@ -127,6 +128,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', () => {

View File

@@ -33,6 +33,7 @@ import {
extractShowValueIndexes,
extractTooltipKeys,
formatSeriesName,
getAreaScaledSymbolSize,
getAxisType,
getChartPadding,
getLegendProps,
@@ -1646,3 +1647,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,
);
});