Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
buildCustomFormatters,
CategoricalColorNamespace,
CurrencyFormatter,
DataRecordValue,
ensureIsArray,
tooltipHtml,
getCustomFormatter,
Expand Down Expand Up @@ -78,6 +79,7 @@ import {
extractSeries,
extractShowValueIndexes,
extractTooltipKeys,
getAreaScaledSymbolSize,
getAxisType,
getColtypesMapping,
getHorizontalLegendAvailableWidth,
Expand Down Expand Up @@ -228,6 +230,8 @@ export default function transformProps(
logAxis,
markerEnabled,
markerSize,
maxMarkerSize = 30,
minMarkerSize = 5,
metrics,
minorSplitLine,
minorTicks,
Expand All @@ -239,6 +243,7 @@ export default function transformProps(
seriesType,
showLegend,
showValue,
size,
colorByPrimaryAxis,
sliceId,
sortSeriesType,
Expand Down Expand Up @@ -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,
Expand All @@ -337,6 +342,94 @@ export default function transformProps(
xAxisType,
},
);

// Dot size by metric (scatter): the size metric's series are excluded from
// rendering and instead provide per-point values that scale each marker's
// area between minMarkerSize and maxMarkerSize.
const sizeMetricLabel =
seriesType === EchartsTimeseriesSeriesType.Scatter && size
? getMetricLabel(size)
: undefined;
const sizeSeriesLabel = isDefined(sizeMetricLabel)
? (verboseMap[sizeMetricLabel!] ?? sizeMetricLabel)
: undefined;
const valueMetricLabels = ensureIsArray(metrics)
.map(getMetricLabel)
.map(label => verboseMap[label] ?? label);
// When the size metric is also a value metric, the query dedupes them into a
// single column, so each point's own value doubles as its size value.
const sizeIsValueMetric = isDefined(sizeSeriesLabel)
? valueMetricLabels.includes(sizeSeriesLabel!)
: false;
const isSizeSeries = (name: string) =>
isDefined(sizeSeriesLabel) &&
!sizeIsValueMetric &&
(name === sizeSeriesLabel || name.startsWith(`${sizeSeriesLabel}, `));
Comment thread
rusackas marked this conversation as resolved.
const rawSeries = sizeSeriesLabel
? allRawSeries.filter(entry => !isSizeSeries(String(entry.name ?? '')))
: allRawSeries;
Comment thread
rusackas marked this conversation as resolved.
// 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);
};
Comment thread
rusackas marked this conversation as resolved.
// 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,
Expand Down Expand Up @@ -472,6 +565,27 @@ export default function transformProps(
}
}

let symbolSizeFn:
| ((value: (number | string | null)[]) => number)
| undefined;
if (sizeExtent) {
const extent = sizeExtent;
const sizeLookup = sizeLookups?.get(getSeriesDimsKey(entryName));
if (sizeIsValueMetric || sizeLookup) {
symbolSizeFn = value => {
const sizeValue = sizeIsValueMetric
? value[isHorizontal ? 0 : 1]
: sizeLookup!.get(value[isHorizontal ? 1 : 0]);
// Points with a missing/invalid size value get the smallest
// configured dot size; the fixed marker size control is hidden
// when a size metric is set, so its value would be stale here.
return typeof sizeValue === 'number'
? getAreaScaledSymbolSize(sizeValue, extent, markerSizeRange)
: markerSizeRange[0];
};
}
}

const transformedSeries = transformSeries(
entry,
colorScale,
Expand All @@ -483,6 +597,7 @@ export default function transformProps(
seriesContexts,
markerEnabled,
markerSize,
symbolSizeFn,
areaOpacity: opacity,
seriesType,
legendState,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,7 @@ export function transformSeries(
seriesContexts?: { [key: string]: ForecastSeriesEnum[] };
markerEnabled?: boolean;
markerSize?: number;
symbolSizeFn?: (value: (number | string | null)[]) => number;
Comment thread
rusackas marked this conversation as resolved.
areaOpacity?: number;
seriesType?: EchartsTimeseriesSeriesType;
stack?: StackType;
Expand Down Expand Up @@ -239,6 +240,7 @@ export function transformSeries(
seriesContexts = {},
markerEnabled,
markerSize,
symbolSizeFn,
areaOpacity = 1,
seriesType,
stack,
Expand Down Expand Up @@ -413,7 +415,7 @@ export function transformSeries(
emphasis,
showSymbol,
symbol,
symbolSize: markerSize,
symbolSize: symbolSizeFn ?? markerSize,
label: {
show: !!showValue,
position: isHorizontal ? 'right' : 'top',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,13 +66,16 @@ export type EchartsTimeseriesFormData = QueryFormData & {
logAxis: boolean;
markerEnabled: boolean;
markerSize: number;
maxMarkerSize?: number;
minMarkerSize?: number;
metrics: QueryFormMetric[];
minorSplitLine: boolean;
minorTicks: boolean;
opacity: number;
orderDesc: boolean;
rowLimit: number;
seriesType: EchartsTimeseriesSeriesType;
size?: QueryFormMetric;
stack: StackType;
stackDimension: string;
timeCompare?: string[];
Expand Down
30 changes: 30 additions & 0 deletions superset-frontend/plugins/plugin-chart-echarts/src/utils/series.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading
Loading