mirror of
https://github.com/apache/superset.git
synced 2026-06-11 10:39:15 +00:00
Compare commits
1 Commits
dependabot
...
feat/scatt
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1fbde07e30 |
4
.github/workflows/codeql-analysis.yml
vendored
4
.github/workflows/codeql-analysis.yml
vendored
@@ -63,7 +63,7 @@ jobs:
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
@@ -74,6 +74,6 @@ jobs:
|
||||
# queries: security-extended,security-and-quality
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
|
||||
@@ -22,14 +22,19 @@ import { GenericDataType } from '@apache-superset/core/common';
|
||||
import {
|
||||
checkColumnType,
|
||||
ControlPanelConfig,
|
||||
ControlPanelSectionConfig,
|
||||
ControlPanelsContainerProps,
|
||||
ControlSetRow,
|
||||
ControlStateMapping,
|
||||
ControlSubSectionHeader,
|
||||
D3_TIME_FORMAT_DOCS,
|
||||
formatSelectOptions,
|
||||
getStandardizedControls,
|
||||
sections,
|
||||
sharedControls,
|
||||
} from '@superset-ui/chart-controls';
|
||||
|
||||
import { OrientationType } from '../../types';
|
||||
import {
|
||||
DEFAULT_FORM_DATA,
|
||||
TIME_SERIES_DESCRIPTION_TEXT,
|
||||
@@ -51,18 +56,342 @@ const {
|
||||
logAxis,
|
||||
markerEnabled,
|
||||
markerSize,
|
||||
maxMarkerSize,
|
||||
minMarkerSize,
|
||||
minorSplitLine,
|
||||
orientation,
|
||||
rowLimit,
|
||||
truncateYAxis,
|
||||
yAxisBounds,
|
||||
} = DEFAULT_FORM_DATA;
|
||||
|
||||
const isHorizontal = (controls: ControlStateMapping) =>
|
||||
controls?.orientation?.value === OrientationType.Horizontal;
|
||||
const isVertical = (controls: ControlStateMapping) => !isHorizontal(controls);
|
||||
|
||||
function createAxisTitleControl(axis: 'x' | 'y'): ControlSetRow[] {
|
||||
const isXAxis = axis === 'x';
|
||||
return [
|
||||
[
|
||||
{
|
||||
name: 'x_axis_title',
|
||||
config: {
|
||||
type: 'TextControl',
|
||||
label: t('Axis Title'),
|
||||
renderTrigger: true,
|
||||
default: '',
|
||||
visibility: ({ controls }: ControlPanelsContainerProps) =>
|
||||
isXAxis ? isVertical(controls) : isHorizontal(controls),
|
||||
disableStash: true,
|
||||
resetOnHide: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
name: 'x_axis_title_margin',
|
||||
config: {
|
||||
type: 'SelectControl',
|
||||
freeForm: true,
|
||||
clearable: true,
|
||||
label: t('Axis title margin'),
|
||||
renderTrigger: true,
|
||||
default: sections.TITLE_MARGIN_OPTIONS[3],
|
||||
choices: formatSelectOptions(sections.TITLE_MARGIN_OPTIONS),
|
||||
visibility: ({ controls }: ControlPanelsContainerProps) =>
|
||||
isXAxis ? isVertical(controls) : isHorizontal(controls),
|
||||
disableStash: true,
|
||||
resetOnHide: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
name: 'y_axis_title',
|
||||
config: {
|
||||
type: 'TextControl',
|
||||
label: t('Axis Title'),
|
||||
renderTrigger: true,
|
||||
default: '',
|
||||
visibility: ({ controls }: ControlPanelsContainerProps) =>
|
||||
isXAxis ? isHorizontal(controls) : isVertical(controls),
|
||||
disableStash: true,
|
||||
resetOnHide: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
name: 'y_axis_title_margin',
|
||||
config: {
|
||||
type: 'SelectControl',
|
||||
freeForm: true,
|
||||
clearable: true,
|
||||
label: t('Axis title margin'),
|
||||
renderTrigger: true,
|
||||
default: sections.TITLE_MARGIN_OPTIONS[4],
|
||||
choices: formatSelectOptions(sections.TITLE_MARGIN_OPTIONS),
|
||||
visibility: ({ controls }: ControlPanelsContainerProps) =>
|
||||
isXAxis ? isHorizontal(controls) : isVertical(controls),
|
||||
disableStash: true,
|
||||
resetOnHide: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
name: 'y_axis_title_position',
|
||||
config: {
|
||||
type: 'SelectControl',
|
||||
freeForm: true,
|
||||
clearable: false,
|
||||
label: t('Axis title position'),
|
||||
renderTrigger: true,
|
||||
default: sections.TITLE_POSITION_OPTIONS[0][0],
|
||||
choices: sections.TITLE_POSITION_OPTIONS,
|
||||
visibility: ({ controls }: ControlPanelsContainerProps) =>
|
||||
isXAxis ? isHorizontal(controls) : isVertical(controls),
|
||||
disableStash: true,
|
||||
resetOnHide: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
function createAxisControl(axis: 'x' | 'y'): ControlSetRow[] {
|
||||
const isXAxis = axis === 'x';
|
||||
// The dimension (x_axis) controls follow the dimension axis: they show
|
||||
// under "X Axis" when vertical and under "Y Axis" when horizontal. The
|
||||
// metric controls follow the opposite axis.
|
||||
const showsDimensionAxis = (controls: ControlStateMapping) =>
|
||||
isXAxis ? isVertical(controls) : isHorizontal(controls);
|
||||
const showsMetricAxis = (controls: ControlStateMapping) =>
|
||||
isXAxis ? isHorizontal(controls) : isVertical(controls);
|
||||
return [
|
||||
[
|
||||
{
|
||||
name: 'x_axis_time_format',
|
||||
config: {
|
||||
...sharedControls.x_axis_time_format,
|
||||
default: 'smart_date',
|
||||
description: `${D3_TIME_FORMAT_DOCS}. ${TIME_SERIES_DESCRIPTION_TEXT}`,
|
||||
visibility: ({ controls }: ControlPanelsContainerProps) =>
|
||||
showsDimensionAxis(controls) &&
|
||||
checkColumnType(
|
||||
getColumnLabel(controls?.x_axis?.value as QueryFormColumn),
|
||||
controls?.datasource?.datasource,
|
||||
[GenericDataType.Temporal],
|
||||
),
|
||||
disableStash: true,
|
||||
resetOnHide: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
name: 'x_axis_number_format',
|
||||
config: {
|
||||
...sharedControls.x_axis_number_format,
|
||||
default: '~g',
|
||||
mapStateToProps: undefined,
|
||||
visibility: ({ controls }: ControlPanelsContainerProps) =>
|
||||
showsDimensionAxis(controls) &&
|
||||
checkColumnType(
|
||||
getColumnLabel(controls?.x_axis?.value as QueryFormColumn),
|
||||
controls?.datasource?.datasource,
|
||||
[GenericDataType.Numeric],
|
||||
),
|
||||
disableStash: true,
|
||||
resetOnHide: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
name: xAxisLabelRotation.name,
|
||||
config: {
|
||||
...xAxisLabelRotation.config,
|
||||
visibility: ({ controls }: ControlPanelsContainerProps) =>
|
||||
showsDimensionAxis(controls),
|
||||
disableStash: true,
|
||||
resetOnHide: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
name: xAxisLabelInterval.name,
|
||||
config: {
|
||||
...xAxisLabelInterval.config,
|
||||
visibility: ({ controls }: ControlPanelsContainerProps) =>
|
||||
showsDimensionAxis(controls),
|
||||
disableStash: true,
|
||||
resetOnHide: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
name: 'y_axis_format',
|
||||
config: {
|
||||
...sharedControls.y_axis_format,
|
||||
label: t('Axis Format'),
|
||||
visibility: ({ controls }: ControlPanelsContainerProps) =>
|
||||
showsMetricAxis(controls),
|
||||
disableStash: true,
|
||||
resetOnHide: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
['currency_format'],
|
||||
[
|
||||
{
|
||||
name: 'logAxis',
|
||||
config: {
|
||||
type: 'CheckboxControl',
|
||||
label: t('Logarithmic axis'),
|
||||
renderTrigger: true,
|
||||
default: logAxis,
|
||||
description: t('Logarithmic axis'),
|
||||
visibility: ({ controls }: ControlPanelsContainerProps) =>
|
||||
showsMetricAxis(controls),
|
||||
disableStash: true,
|
||||
resetOnHide: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
name: 'minorSplitLine',
|
||||
config: {
|
||||
type: 'CheckboxControl',
|
||||
label: t('Minor Split Line'),
|
||||
renderTrigger: true,
|
||||
default: minorSplitLine,
|
||||
description: t('Draw split lines for minor axis ticks'),
|
||||
visibility: ({ controls }: ControlPanelsContainerProps) =>
|
||||
showsMetricAxis(controls),
|
||||
disableStash: true,
|
||||
resetOnHide: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
name: 'truncateYAxis',
|
||||
config: {
|
||||
type: 'CheckboxControl',
|
||||
label: t('Truncate Axis'),
|
||||
default: truncateYAxis,
|
||||
renderTrigger: true,
|
||||
description: t(
|
||||
'Truncate the metric axis. Can be overridden by specifying a min or max bound.',
|
||||
),
|
||||
visibility: ({ controls }: ControlPanelsContainerProps) =>
|
||||
showsMetricAxis(controls),
|
||||
disableStash: true,
|
||||
resetOnHide: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
name: 'y_axis_bounds',
|
||||
config: {
|
||||
type: 'BoundsControl',
|
||||
label: t('Axis Bounds'),
|
||||
renderTrigger: true,
|
||||
default: yAxisBounds,
|
||||
description: t(
|
||||
'Bounds for the axis. When left empty, the bounds are ' +
|
||||
'dynamically defined based on the min/max of the data. Note that ' +
|
||||
"this feature will only expand the axis range. It won't " +
|
||||
"narrow the data's extent.",
|
||||
),
|
||||
visibility: ({ controls }: ControlPanelsContainerProps) =>
|
||||
Boolean(controls?.truncateYAxis?.value) &&
|
||||
showsMetricAxis(controls),
|
||||
disableStash: true,
|
||||
resetOnHide: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
const sizeMetricRow: ControlSetRow = [
|
||||
{
|
||||
name: 'size',
|
||||
config: {
|
||||
...sharedControls.size,
|
||||
label: t('Dot size metric'),
|
||||
description: t(
|
||||
'Optional metric used to scale the size of each dot. Dot areas are ' +
|
||||
'scaled linearly between the minimum and maximum dot size.',
|
||||
),
|
||||
validators: [],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const queryRows: ControlSetRow[] = [
|
||||
...sections.echartsTimeSeriesQueryWithXAxisSort.controlSetRows,
|
||||
];
|
||||
const groupbyRowIndex = queryRows.findIndex(
|
||||
row => row.length === 1 && row[0] === 'groupby',
|
||||
);
|
||||
queryRows.splice(
|
||||
groupbyRowIndex === -1 ? queryRows.length : groupbyRowIndex + 1,
|
||||
0,
|
||||
sizeMetricRow,
|
||||
);
|
||||
const querySection: ControlPanelSectionConfig = {
|
||||
...sections.echartsTimeSeriesQueryWithXAxisSort,
|
||||
controlSetRows: queryRows,
|
||||
};
|
||||
|
||||
const config: ControlPanelConfig = {
|
||||
controlPanelSections: [
|
||||
sections.echartsTimeSeriesQueryWithXAxisSort,
|
||||
querySection,
|
||||
sections.advancedAnalyticsControls,
|
||||
sections.annotationsAndLayersControls,
|
||||
sections.forecastIntervalControls,
|
||||
sections.titleControls,
|
||||
{
|
||||
label: t('Chart Orientation'),
|
||||
expanded: true,
|
||||
controlSetRows: [
|
||||
[
|
||||
{
|
||||
name: 'orientation',
|
||||
config: {
|
||||
type: 'RadioButtonControl',
|
||||
renderTrigger: true,
|
||||
label: t('Chart orientation'),
|
||||
default: orientation,
|
||||
options: [
|
||||
[OrientationType.Vertical, t('Vertical')],
|
||||
[OrientationType.Horizontal, t('Horizontal')],
|
||||
],
|
||||
description: t(
|
||||
'Orientation of the chart. Horizontal places the dimension on the y-axis and the metric on the x-axis.',
|
||||
),
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
{
|
||||
label: t('Chart Title'),
|
||||
tabOverride: 'customize',
|
||||
expanded: true,
|
||||
controlSetRows: [
|
||||
[<ControlSubSectionHeader>{t('X Axis')}</ControlSubSectionHeader>],
|
||||
...createAxisTitleControl('x'),
|
||||
[<ControlSubSectionHeader>{t('Y Axis')}</ControlSubSectionHeader>],
|
||||
...createAxisTitleControl('y'),
|
||||
],
|
||||
},
|
||||
{
|
||||
label: t('Chart Options'),
|
||||
expanded: true,
|
||||
@@ -99,7 +428,42 @@ const config: ControlPanelConfig = {
|
||||
'Size of marker. Also applies to forecast observations.',
|
||||
),
|
||||
visibility: ({ controls }: ControlPanelsContainerProps) =>
|
||||
Boolean(controls?.markerEnabled?.value),
|
||||
Boolean(controls?.markerEnabled?.value) &&
|
||||
!controls?.size?.value,
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
name: 'minMarkerSize',
|
||||
config: {
|
||||
type: 'SliderControl',
|
||||
label: t('Minimum dot size'),
|
||||
renderTrigger: true,
|
||||
min: 1,
|
||||
max: 100,
|
||||
default: minMarkerSize,
|
||||
description: t(
|
||||
'Size of the dot representing the smallest value of the dot size metric.',
|
||||
),
|
||||
visibility: ({ controls }: ControlPanelsContainerProps) =>
|
||||
Boolean(controls?.size?.value),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'maxMarkerSize',
|
||||
config: {
|
||||
type: 'SliderControl',
|
||||
label: t('Maximum dot size'),
|
||||
renderTrigger: true,
|
||||
min: 1,
|
||||
max: 100,
|
||||
default: maxMarkerSize,
|
||||
description: t(
|
||||
'Size of the dot representing the largest value of the dot size metric.',
|
||||
),
|
||||
visibility: ({ controls }: ControlPanelsContainerProps) =>
|
||||
Boolean(controls?.size?.value),
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -107,107 +471,13 @@ const config: ControlPanelConfig = {
|
||||
[minorTicks],
|
||||
...legendSection,
|
||||
[<ControlSubSectionHeader>{t('X Axis')}</ControlSubSectionHeader>],
|
||||
|
||||
[
|
||||
{
|
||||
name: 'x_axis_time_format',
|
||||
config: {
|
||||
...sharedControls.x_axis_time_format,
|
||||
default: 'smart_date',
|
||||
description: `${D3_TIME_FORMAT_DOCS}. ${TIME_SERIES_DESCRIPTION_TEXT}`,
|
||||
visibility: ({ controls }: ControlPanelsContainerProps) =>
|
||||
checkColumnType(
|
||||
getColumnLabel(controls?.x_axis?.value as QueryFormColumn),
|
||||
controls?.datasource?.datasource,
|
||||
[GenericDataType.Temporal],
|
||||
),
|
||||
disableStash: true,
|
||||
resetOnHide: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'x_axis_number_format',
|
||||
config: {
|
||||
...sharedControls.x_axis_number_format,
|
||||
default: '~g',
|
||||
mapStateToProps: undefined,
|
||||
visibility: ({ controls }: ControlPanelsContainerProps) =>
|
||||
checkColumnType(
|
||||
getColumnLabel(controls?.x_axis?.value as QueryFormColumn),
|
||||
controls?.datasource?.datasource,
|
||||
[GenericDataType.Numeric],
|
||||
),
|
||||
},
|
||||
},
|
||||
],
|
||||
[xAxisLabelRotation],
|
||||
[xAxisLabelInterval],
|
||||
[forceMaxInterval],
|
||||
// eslint-disable-next-line react/jsx-key
|
||||
...richTooltipSection,
|
||||
// eslint-disable-next-line react/jsx-key
|
||||
[<ControlSubSectionHeader>{t('Y Axis')}</ControlSubSectionHeader>],
|
||||
['y_axis_format'],
|
||||
['currency_format'],
|
||||
[
|
||||
{
|
||||
name: 'logAxis',
|
||||
config: {
|
||||
type: 'CheckboxControl',
|
||||
label: t('Logarithmic y-axis'),
|
||||
renderTrigger: true,
|
||||
default: logAxis,
|
||||
description: t('Logarithmic y-axis'),
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
name: 'minorSplitLine',
|
||||
config: {
|
||||
type: 'CheckboxControl',
|
||||
label: t('Minor Split Line'),
|
||||
renderTrigger: true,
|
||||
default: minorSplitLine,
|
||||
description: t('Draw split lines for minor y-axis ticks'),
|
||||
},
|
||||
},
|
||||
],
|
||||
...createAxisControl('x'),
|
||||
[truncateXAxis],
|
||||
[xAxisBounds],
|
||||
[
|
||||
{
|
||||
name: 'truncateYAxis',
|
||||
config: {
|
||||
type: 'CheckboxControl',
|
||||
label: t('Truncate Y Axis'),
|
||||
default: truncateYAxis,
|
||||
renderTrigger: true,
|
||||
description: t(
|
||||
'Truncate Y Axis. Can be overridden by specifying a min or max bound.',
|
||||
),
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
name: 'y_axis_bounds',
|
||||
config: {
|
||||
type: 'BoundsControl',
|
||||
label: t('Y Axis Bounds'),
|
||||
renderTrigger: true,
|
||||
default: yAxisBounds,
|
||||
description: t(
|
||||
'Bounds for the Y-axis. When left empty, the bounds are ' +
|
||||
'dynamically defined based on the min/max of the data. Note that ' +
|
||||
"this feature will only expand the axis range. It won't " +
|
||||
"narrow the data's extent.",
|
||||
),
|
||||
visibility: ({ controls }: ControlPanelsContainerProps) =>
|
||||
Boolean(controls?.truncateYAxis?.value),
|
||||
},
|
||||
},
|
||||
],
|
||||
[forceMaxInterval],
|
||||
...richTooltipSection,
|
||||
[<ControlSubSectionHeader>{t('Y Axis')}</ControlSubSectionHeader>],
|
||||
...createAxisControl('y'),
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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,89 @@ export default function transformProps(
|
||||
xAxisType,
|
||||
},
|
||||
);
|
||||
|
||||
// Dot size by metric (scatter): the size metric's series are excluded from
|
||||
// rendering and instead provide per-point values that scale each marker's
|
||||
// area between minMarkerSize and maxMarkerSize.
|
||||
const sizeMetricLabel =
|
||||
seriesType === EchartsTimeseriesSeriesType.Scatter && size
|
||||
? getMetricLabel(size)
|
||||
: undefined;
|
||||
const sizeSeriesLabel = isDefined(sizeMetricLabel)
|
||||
? (verboseMap[sizeMetricLabel!] ?? sizeMetricLabel)
|
||||
: undefined;
|
||||
const valueMetricLabels = ensureIsArray(metrics)
|
||||
.map(getMetricLabel)
|
||||
.map(label => verboseMap[label] ?? label);
|
||||
// When the size metric is also a value metric, the query dedupes them into a
|
||||
// single column, so each point's own value doubles as its size value.
|
||||
const sizeIsValueMetric = isDefined(sizeSeriesLabel)
|
||||
? valueMetricLabels.includes(sizeSeriesLabel!)
|
||||
: false;
|
||||
const isSizeSeries = (name: string) =>
|
||||
isDefined(sizeSeriesLabel) &&
|
||||
!sizeIsValueMetric &&
|
||||
(name === sizeSeriesLabel || name.startsWith(`${sizeSeriesLabel}, `));
|
||||
const rawSeries = sizeSeriesLabel
|
||||
? allRawSeries.filter(entry => !isSizeSeries(String(entry.name ?? '')))
|
||||
: allRawSeries;
|
||||
// Maps each value series' dimension key to a lookup from primary-axis value
|
||||
// to size value.
|
||||
let sizeLookups: Map<string, Map<DataRecordValue, number>> | undefined;
|
||||
let sizeExtent: [number, number] | undefined;
|
||||
if (sizeSeriesLabel) {
|
||||
let sizeMin = Infinity;
|
||||
let sizeMax = -Infinity;
|
||||
if (sizeIsValueMetric) {
|
||||
rawSeries.forEach(entry => {
|
||||
(entry.data as DataRecordValue[][]).forEach(datum => {
|
||||
const sizeValue = isHorizontal ? datum[0] : datum[1];
|
||||
if (typeof sizeValue === 'number' && Number.isFinite(sizeValue)) {
|
||||
sizeMin = Math.min(sizeMin, sizeValue);
|
||||
sizeMax = Math.max(sizeMax, sizeValue);
|
||||
}
|
||||
});
|
||||
});
|
||||
} else {
|
||||
sizeLookups = new Map();
|
||||
allRawSeries
|
||||
.filter(entry => isSizeSeries(String(entry.name ?? '')))
|
||||
.forEach(entry => {
|
||||
const name = String(entry.name ?? '');
|
||||
const dimsKey =
|
||||
name === sizeSeriesLabel
|
||||
? ''
|
||||
: name.slice(sizeSeriesLabel.length + 2);
|
||||
const lookup = new Map<DataRecordValue, number>();
|
||||
(entry.data as DataRecordValue[][]).forEach(datum => {
|
||||
const axisValue = isHorizontal ? datum[1] : datum[0];
|
||||
const sizeValue = isHorizontal ? datum[0] : datum[1];
|
||||
if (typeof sizeValue === 'number' && Number.isFinite(sizeValue)) {
|
||||
lookup.set(axisValue, sizeValue);
|
||||
sizeMin = Math.min(sizeMin, sizeValue);
|
||||
sizeMax = Math.max(sizeMax, sizeValue);
|
||||
}
|
||||
});
|
||||
sizeLookups!.set(dimsKey, lookup);
|
||||
});
|
||||
}
|
||||
if (sizeMin <= sizeMax) {
|
||||
sizeExtent = [sizeMin, sizeMax];
|
||||
}
|
||||
}
|
||||
// Strips the metric label off a series name, leaving the dimension key used
|
||||
// to match a value series with its size series.
|
||||
const getSeriesDimsKey = (name: string): string => {
|
||||
const matchedLabel = valueMetricLabels.find(
|
||||
label => name === label || name.startsWith(`${label}, `),
|
||||
);
|
||||
if (matchedLabel === undefined) {
|
||||
return name;
|
||||
}
|
||||
return name === matchedLabel ? '' : name.slice(matchedLabel.length + 2);
|
||||
};
|
||||
const markerSizeRange: [number, number] = [minMarkerSize, maxMarkerSize];
|
||||
|
||||
const showValueIndexes = extractShowValueIndexes(rawSeries, {
|
||||
stack,
|
||||
onlyTotal,
|
||||
@@ -472,6 +560,24 @@ export default function transformProps(
|
||||
}
|
||||
}
|
||||
|
||||
let symbolSizeFn:
|
||||
| ((value: (number | string | null)[]) => number)
|
||||
| undefined;
|
||||
if (sizeExtent) {
|
||||
const extent = sizeExtent;
|
||||
const sizeLookup = sizeLookups?.get(getSeriesDimsKey(entryName));
|
||||
if (sizeIsValueMetric || sizeLookup) {
|
||||
symbolSizeFn = value => {
|
||||
const sizeValue = sizeIsValueMetric
|
||||
? value[isHorizontal ? 0 : 1]
|
||||
: sizeLookup!.get(value[isHorizontal ? 1 : 0]);
|
||||
return typeof sizeValue === 'number'
|
||||
? getAreaScaledSymbolSize(sizeValue, extent, markerSizeRange)
|
||||
: markerSize;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const transformedSeries = transformSeries(
|
||||
entry,
|
||||
colorScale,
|
||||
@@ -483,6 +589,7 @@ export default function transformProps(
|
||||
seriesContexts,
|
||||
markerEnabled,
|
||||
markerSize,
|
||||
symbolSizeFn,
|
||||
areaOpacity: opacity,
|
||||
seriesType,
|
||||
legendState,
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -930,6 +930,36 @@ export function sanitizeHtml(text: string): string {
|
||||
return format.encodeHTML(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a metric value to a marker diameter such that the marker's *area*
|
||||
* (not its diameter) scales linearly with the value between the smallest
|
||||
* and largest observed values. Area-based scaling avoids the perceptual
|
||||
* exaggeration that diameter-linear scaling causes, where a 2x value
|
||||
* renders as a 4x area.
|
||||
*
|
||||
* @param value - the metric value for this data point
|
||||
* @param valueExtent - [min, max] of the metric across all data points
|
||||
* @param sizeRange - [min, max] marker diameter in pixels
|
||||
*/
|
||||
export function getAreaScaledSymbolSize(
|
||||
value: number,
|
||||
valueExtent: [number, number],
|
||||
sizeRange: [number, number],
|
||||
): number {
|
||||
const [minValue, maxValue] = valueExtent;
|
||||
const [minSize, maxSize] = sizeRange;
|
||||
if (!Number.isFinite(value) || maxValue === minValue) {
|
||||
// single-valued or invalid data: use the diameter whose area is the
|
||||
// midpoint of the configured area range
|
||||
return Math.sqrt((minSize ** 2 + maxSize ** 2) / 2);
|
||||
}
|
||||
const ratio = Math.min(
|
||||
Math.max((value - minValue) / (maxValue - minValue), 0),
|
||||
1,
|
||||
);
|
||||
return Math.sqrt(minSize ** 2 + ratio * (maxSize ** 2 - minSize ** 2));
|
||||
}
|
||||
|
||||
export function getAxisType(
|
||||
stack: StackType,
|
||||
forceCategorical?: boolean,
|
||||
|
||||
@@ -24,7 +24,7 @@ const config = controlPanel;
|
||||
|
||||
const getControl = (controlName: string) => {
|
||||
for (const section of config.controlPanelSections) {
|
||||
if (section && section.controlSetRows) {
|
||||
if (section?.controlSetRows) {
|
||||
for (const row of section.controlSetRows) {
|
||||
for (const control of row) {
|
||||
if (
|
||||
@@ -148,3 +148,59 @@ test('x_axis_number_format control should be hidden for non-numeric data types',
|
||||
expect(isNumberVisible(null, null)).toBe(false);
|
||||
expect(isNumberVisible('time_column', GenericDataType.Temporal)).toBe(false);
|
||||
});
|
||||
|
||||
// tests for orientation and dot size controls
|
||||
const orientationControl: any = getControl('orientation');
|
||||
const sizeControl: any = getControl('size');
|
||||
const minMarkerSizeControl: any = getControl('minMarkerSize');
|
||||
const maxMarkerSizeControl: any = getControl('maxMarkerSize');
|
||||
|
||||
test('scatter chart control panel should include an orientation control defaulting to vertical', () => {
|
||||
expect(orientationControl).toBeDefined();
|
||||
expect(orientationControl.config.default).toBe('vertical');
|
||||
expect(orientationControl.config.options).toEqual([
|
||||
['vertical', expect.anything()],
|
||||
['horizontal', expect.anything()],
|
||||
]);
|
||||
});
|
||||
|
||||
test('scatter chart control panel should include an optional dot size metric control', () => {
|
||||
expect(sizeControl).toBeDefined();
|
||||
expect(sizeControl.config.validators).toEqual([]);
|
||||
expect(sizeControl.config.default).toBeNull();
|
||||
});
|
||||
|
||||
const mockSizeControls = (
|
||||
sizeValue: string | null,
|
||||
): ControlPanelsContainerProps =>
|
||||
({
|
||||
controls: {
|
||||
size: { value: sizeValue },
|
||||
markerEnabled: { value: true },
|
||||
},
|
||||
}) as unknown as ControlPanelsContainerProps;
|
||||
|
||||
test('dot size range controls should only be visible when a size metric is set', () => {
|
||||
expect(minMarkerSizeControl.config.visibility(mockSizeControls(null))).toBe(
|
||||
false,
|
||||
);
|
||||
expect(maxMarkerSizeControl.config.visibility(mockSizeControls(null))).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
minMarkerSizeControl.config.visibility(mockSizeControls('size_metric')),
|
||||
).toBe(true);
|
||||
expect(
|
||||
maxMarkerSizeControl.config.visibility(mockSizeControls('size_metric')),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('fixed marker size control should hide when a size metric is set', () => {
|
||||
const markerSizeControl: any = getControl('markerSize');
|
||||
expect(markerSizeControl.config.visibility(mockSizeControls(null))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
markerSizeControl.config.visibility(mockSizeControls('size_metric')),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
@@ -175,3 +175,283 @@ describe('Scatter Chart X-axis Number Formatting', () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('Scatter Chart Orientation and Dot Size Metric', () => {
|
||||
const baseFormData: EchartsTimeseriesFormData = {
|
||||
...DEFAULT_FORM_DATA,
|
||||
colorScheme: 'supersetColors',
|
||||
datasource: '1__table',
|
||||
metrics: ['sum_val'],
|
||||
x_axis: 'category_col',
|
||||
groupby: [],
|
||||
viz_type: 'echarts_timeseries_scatter',
|
||||
seriesType: EchartsTimeseriesSeriesType.Scatter,
|
||||
};
|
||||
|
||||
const categoricalData = [
|
||||
{
|
||||
data: [
|
||||
{ category_col: 'A', sum_val: 1, size_metric: 10 },
|
||||
{ category_col: 'B', sum_val: 2, size_metric: 25 },
|
||||
{ category_col: 'C', sum_val: 3, size_metric: 40 },
|
||||
],
|
||||
colnames: ['category_col', 'sum_val', 'size_metric'],
|
||||
coltypes: [
|
||||
GenericDataType.String,
|
||||
GenericDataType.Numeric,
|
||||
GenericDataType.Numeric,
|
||||
],
|
||||
label_map: {
|
||||
category_col: ['category_col'],
|
||||
sum_val: ['sum_val'],
|
||||
size_metric: ['size_metric'],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const baseChartPropsConfig = {
|
||||
width: 800,
|
||||
height: 600,
|
||||
theme: supersetTheme,
|
||||
};
|
||||
|
||||
const getScatterSeries = (props: ReturnType<typeof transformProps>) =>
|
||||
(props.echartOptions.series as any[]).filter(s => s.type === 'scatter');
|
||||
|
||||
const singleMetricData = [
|
||||
{
|
||||
...categoricalData[0],
|
||||
data: categoricalData[0].data.map(row => ({
|
||||
category_col: row.category_col,
|
||||
sum_val: row.sum_val,
|
||||
})),
|
||||
colnames: ['category_col', 'sum_val'],
|
||||
coltypes: [GenericDataType.String, GenericDataType.Numeric],
|
||||
},
|
||||
];
|
||||
|
||||
test('horizontal orientation swaps the dimension axis onto the y-axis', () => {
|
||||
const chartProps = new ChartProps({
|
||||
...baseChartPropsConfig,
|
||||
queriesData: singleMetricData,
|
||||
formData: { ...baseFormData, orientation: 'horizontal' },
|
||||
});
|
||||
|
||||
const transformedProps = transformProps(
|
||||
chartProps as EchartsTimeseriesChartProps,
|
||||
);
|
||||
const { xAxis, yAxis } = transformedProps.echartOptions as any;
|
||||
expect(yAxis.type).toBe('category');
|
||||
expect(xAxis.type).toBe('value');
|
||||
|
||||
const series = getScatterSeries(transformedProps);
|
||||
expect(series).toHaveLength(1);
|
||||
// data points are flipped to [metric, dimension]
|
||||
expect(series[0].data[0]).toEqual([1, 'A']);
|
||||
});
|
||||
|
||||
test('vertical orientation keeps the dimension axis on the x-axis', () => {
|
||||
const chartProps = new ChartProps({
|
||||
...baseChartPropsConfig,
|
||||
queriesData: singleMetricData,
|
||||
formData: baseFormData,
|
||||
});
|
||||
|
||||
const transformedProps = transformProps(
|
||||
chartProps as EchartsTimeseriesChartProps,
|
||||
);
|
||||
const { xAxis, yAxis } = transformedProps.echartOptions as any;
|
||||
expect(xAxis.type).toBe('category');
|
||||
expect(yAxis.type).toBe('value');
|
||||
|
||||
const series = getScatterSeries(transformedProps);
|
||||
expect(series[0].data[0]).toEqual(['A', 1]);
|
||||
});
|
||||
|
||||
test('size metric series is not rendered or shown in the legend', () => {
|
||||
const chartProps = new ChartProps({
|
||||
...baseChartPropsConfig,
|
||||
queriesData: categoricalData,
|
||||
formData: { ...baseFormData, size: 'size_metric' },
|
||||
});
|
||||
|
||||
const transformedProps = transformProps(
|
||||
chartProps as EchartsTimeseriesChartProps,
|
||||
);
|
||||
const series = getScatterSeries(transformedProps);
|
||||
expect(series).toHaveLength(1);
|
||||
expect(series[0].name).toBe('sum_val');
|
||||
expect(transformedProps.legendData).toEqual(['sum_val']);
|
||||
});
|
||||
|
||||
test('size metric scales marker areas between min and max dot size', () => {
|
||||
const chartProps = new ChartProps({
|
||||
...baseChartPropsConfig,
|
||||
queriesData: categoricalData,
|
||||
formData: {
|
||||
...baseFormData,
|
||||
size: 'size_metric',
|
||||
minMarkerSize: 5,
|
||||
maxMarkerSize: 30,
|
||||
},
|
||||
});
|
||||
|
||||
const transformedProps = transformProps(
|
||||
chartProps as EchartsTimeseriesChartProps,
|
||||
);
|
||||
const [series] = getScatterSeries(transformedProps);
|
||||
expect(typeof series.symbolSize).toBe('function');
|
||||
// smallest size value -> min dot size, largest -> max dot size
|
||||
expect(series.symbolSize(['A', 1])).toBe(5);
|
||||
expect(series.symbolSize(['C', 3])).toBe(30);
|
||||
// midpoint value -> midpoint *area*, not midpoint diameter
|
||||
expect(series.symbolSize(['B', 2]) ** 2).toBeCloseTo(
|
||||
(5 ** 2 + 30 ** 2) / 2,
|
||||
);
|
||||
});
|
||||
|
||||
test('size metric lookups follow the dimension key when grouped', () => {
|
||||
const groupedData = [
|
||||
{
|
||||
data: [
|
||||
{
|
||||
category_col: 'A',
|
||||
'sum_val, g1': 1,
|
||||
'size_metric, g1': 10,
|
||||
'sum_val, g2': 2,
|
||||
'size_metric, g2': 40,
|
||||
},
|
||||
],
|
||||
colnames: [
|
||||
'category_col',
|
||||
'sum_val, g1',
|
||||
'size_metric, g1',
|
||||
'sum_val, g2',
|
||||
'size_metric, g2',
|
||||
],
|
||||
coltypes: [
|
||||
GenericDataType.String,
|
||||
GenericDataType.Numeric,
|
||||
GenericDataType.Numeric,
|
||||
GenericDataType.Numeric,
|
||||
GenericDataType.Numeric,
|
||||
],
|
||||
label_map: {
|
||||
category_col: ['category_col'],
|
||||
'sum_val, g1': ['sum_val', 'g1'],
|
||||
'size_metric, g1': ['size_metric', 'g1'],
|
||||
'sum_val, g2': ['sum_val', 'g2'],
|
||||
'size_metric, g2': ['size_metric', 'g2'],
|
||||
},
|
||||
},
|
||||
];
|
||||
const chartProps = new ChartProps({
|
||||
...baseChartPropsConfig,
|
||||
queriesData: groupedData,
|
||||
formData: {
|
||||
...baseFormData,
|
||||
groupby: ['group_col'],
|
||||
size: 'size_metric',
|
||||
minMarkerSize: 5,
|
||||
maxMarkerSize: 30,
|
||||
},
|
||||
});
|
||||
|
||||
const transformedProps = transformProps(
|
||||
chartProps as EchartsTimeseriesChartProps,
|
||||
);
|
||||
const series = getScatterSeries(transformedProps);
|
||||
expect(series.map((s: any) => s.name).sort()).toEqual([
|
||||
'sum_val, g1',
|
||||
'sum_val, g2',
|
||||
]);
|
||||
const g1 = series.find((s: any) => s.name === 'sum_val, g1');
|
||||
const g2 = series.find((s: any) => s.name === 'sum_val, g2');
|
||||
// the size extent is global: g1's point holds the minimum (10), g2's the
|
||||
// maximum (40)
|
||||
expect(g1.symbolSize(['A', 1])).toBe(5);
|
||||
expect(g2.symbolSize(['A', 2])).toBe(30);
|
||||
});
|
||||
|
||||
test('horizontal orientation and size metric compose', () => {
|
||||
const chartProps = new ChartProps({
|
||||
...baseChartPropsConfig,
|
||||
queriesData: categoricalData,
|
||||
formData: {
|
||||
...baseFormData,
|
||||
orientation: 'horizontal',
|
||||
size: 'size_metric',
|
||||
minMarkerSize: 5,
|
||||
maxMarkerSize: 30,
|
||||
},
|
||||
});
|
||||
|
||||
const transformedProps = transformProps(
|
||||
chartProps as EchartsTimeseriesChartProps,
|
||||
);
|
||||
const [series] = getScatterSeries(transformedProps);
|
||||
expect(series.data[0]).toEqual([1, 'A']);
|
||||
// with flipped data, the dimension value is at index 1
|
||||
expect(series.symbolSize([1, 'A'])).toBe(5);
|
||||
expect(series.symbolSize([3, 'C'])).toBe(30);
|
||||
});
|
||||
|
||||
test('points without a size value fall back to the fixed marker size', () => {
|
||||
const chartProps = new ChartProps({
|
||||
...baseChartPropsConfig,
|
||||
queriesData: [
|
||||
{
|
||||
...categoricalData[0],
|
||||
data: [
|
||||
{ category_col: 'A', sum_val: 1, size_metric: 10 },
|
||||
{ category_col: 'B', sum_val: 2, size_metric: null },
|
||||
{ category_col: 'C', sum_val: 3, size_metric: 40 },
|
||||
],
|
||||
},
|
||||
],
|
||||
formData: { ...baseFormData, size: 'size_metric', markerSize: 7 },
|
||||
});
|
||||
|
||||
const transformedProps = transformProps(
|
||||
chartProps as EchartsTimeseriesChartProps,
|
||||
);
|
||||
const [series] = getScatterSeries(transformedProps);
|
||||
expect(series.symbolSize(['B', 2])).toBe(7);
|
||||
});
|
||||
|
||||
test('size metric equal to the value metric sizes dots by their own value', () => {
|
||||
const dedupedData = [
|
||||
{
|
||||
data: [
|
||||
{ category_col: 'A', sum_val: 1 },
|
||||
{ category_col: 'B', sum_val: 2 },
|
||||
{ category_col: 'C', sum_val: 3 },
|
||||
],
|
||||
colnames: ['category_col', 'sum_val'],
|
||||
coltypes: [GenericDataType.String, GenericDataType.Numeric],
|
||||
label_map: {
|
||||
category_col: ['category_col'],
|
||||
sum_val: ['sum_val'],
|
||||
},
|
||||
},
|
||||
];
|
||||
const chartProps = new ChartProps({
|
||||
...baseChartPropsConfig,
|
||||
queriesData: dedupedData,
|
||||
formData: {
|
||||
...baseFormData,
|
||||
size: 'sum_val',
|
||||
minMarkerSize: 5,
|
||||
maxMarkerSize: 30,
|
||||
},
|
||||
});
|
||||
|
||||
const transformedProps = transformProps(
|
||||
chartProps as EchartsTimeseriesChartProps,
|
||||
);
|
||||
const series = getScatterSeries(transformedProps);
|
||||
expect(series).toHaveLength(1);
|
||||
expect(series[0].symbolSize(['A', 1])).toBe(5);
|
||||
expect(series[0].symbolSize(['C', 3])).toBe(30);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
extractShowValueIndexes,
|
||||
extractTooltipKeys,
|
||||
formatSeriesName,
|
||||
getAreaScaledSymbolSize,
|
||||
getAxisType,
|
||||
getChartPadding,
|
||||
getLegendProps,
|
||||
@@ -1598,3 +1599,29 @@ test('extractTooltipKeys with non-rich tooltip', () => {
|
||||
const result = extractTooltipKeys(forecastValue, 1, false, false);
|
||||
expect(result).toEqual(['foo']);
|
||||
});
|
||||
|
||||
test('getAreaScaledSymbolSize maps the value extent to the size range', () => {
|
||||
// smallest value renders at the minimum diameter
|
||||
expect(getAreaScaledSymbolSize(10, [10, 40], [5, 30])).toBe(5);
|
||||
// largest value renders at the maximum diameter
|
||||
expect(getAreaScaledSymbolSize(40, [10, 40], [5, 30])).toBe(30);
|
||||
});
|
||||
|
||||
test('getAreaScaledSymbolSize scales area, not diameter', () => {
|
||||
// the midpoint value's *area* is halfway between the min and max areas
|
||||
const midSize = getAreaScaledSymbolSize(25, [10, 40], [5, 30]);
|
||||
expect(midSize ** 2).toBeCloseTo((5 ** 2 + 30 ** 2) / 2);
|
||||
});
|
||||
|
||||
test('getAreaScaledSymbolSize clamps values outside the extent', () => {
|
||||
expect(getAreaScaledSymbolSize(-100, [10, 40], [5, 30])).toBe(5);
|
||||
expect(getAreaScaledSymbolSize(1000, [10, 40], [5, 30])).toBe(30);
|
||||
});
|
||||
|
||||
test('getAreaScaledSymbolSize handles degenerate extents and bad values', () => {
|
||||
const midAreaSize = Math.sqrt((5 ** 2 + 30 ** 2) / 2);
|
||||
expect(getAreaScaledSymbolSize(7, [7, 7], [5, 30])).toBeCloseTo(midAreaSize);
|
||||
expect(getAreaScaledSymbolSize(NaN, [10, 40], [5, 30])).toBeCloseTo(
|
||||
midAreaSize,
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user