mirror of
https://github.com/apache/superset.git
synced 2026-07-27 09:02:29 +00:00
Compare commits
9 Commits
dependabot
...
feat/scatt
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a82191af1 | ||
|
|
3ceaa54341 | ||
|
|
372802b313 | ||
|
|
039fbb245c | ||
|
|
7f1c3f92b0 | ||
|
|
01569a90fa | ||
|
|
c279ae4e83 | ||
|
|
f79ce4ee82 | ||
|
|
4dba1c8409 |
@@ -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'),
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user