feat(plugin-chart-echarts): implement event interval and timeseries annotations (#828)

This commit is contained in:
Ville Brofeldt 2020-11-10 20:45:42 +02:00 committed by Yongjie Zhao
parent 5f5e275279
commit 8bfaf4eb0e
15 changed files with 986 additions and 323 deletions

View File

@ -1,13 +1,41 @@
/* eslint camelcase: 0 */
export type AnnotationOpacity = '' | 'opacityLow' | 'opacityMedium' | 'opacityHigh';
import { DataRecord } from '../../chart';
export enum AnnotationType {
Event = 'EVENT',
Formula = 'FORMULA',
Interval = 'INTERVAL',
Timeseries = 'TIME_SERIES',
}
export enum AnnotationSourceType {
Line = 'line',
Native = 'NATIVE',
Table = 'table',
Undefined = '',
}
export enum AnnotationOpacity {
High = 'opacityHigh',
Low = 'opacityLow',
Medium = 'opacityMedium',
Undefined = '',
}
export enum AnnotationStyle {
Dashed = 'dashed',
Dotted = 'dotted',
Solid = 'solid',
LongDashed = 'longDashed',
}
type BaseAnnotationLayer = {
color?: string | null;
name: string;
opacity?: AnnotationOpacity;
show: boolean;
style: 'dashed' | 'dotted' | 'solid' | 'longDashed';
style: AnnotationStyle;
width?: number;
};
@ -21,14 +49,14 @@ type AnnotationOverrides = {
type LineSourceAnnotationLayer = {
hideLine?: boolean;
overrides?: AnnotationOverrides;
sourceType: 'line';
titleColumn: string;
sourceType: AnnotationSourceType.Line;
titleColumn?: string;
// viz id
value: number;
};
type NativeSourceAnnotationLayer = {
sourceType: 'NATIVE';
sourceType: AnnotationSourceType.Native;
// annotation id
value: number;
};
@ -38,7 +66,7 @@ type TableSourceAnnotationLayer = {
timeColumn?: string;
intervalEndColumn?: string;
overrides?: AnnotationOverrides;
sourceType: 'table';
sourceType: AnnotationSourceType.Table;
titleColumn?: string;
// viz id
value: number;
@ -46,24 +74,29 @@ type TableSourceAnnotationLayer = {
export type EventAnnotationLayer = BaseAnnotationLayer &
(TableSourceAnnotationLayer | NativeSourceAnnotationLayer) & {
annotationType: 'EVENT';
annotationType: AnnotationType.Event;
};
export type IntervalAnnotationLayer = BaseAnnotationLayer &
(TableSourceAnnotationLayer | NativeSourceAnnotationLayer) & {
annotationType: 'INTERVAL';
annotationType: AnnotationType.Interval;
};
export type TableAnnotationLayer = BaseAnnotationLayer &
TableSourceAnnotationLayer & {
annotationType: AnnotationType.Event | AnnotationType.Interval;
};
export type FormulaAnnotationLayer = BaseAnnotationLayer & {
annotationType: 'FORMULA';
annotationType: AnnotationType.Formula;
// the mathjs parseable formula
sourceType?: '';
sourceType?: AnnotationSourceType.Undefined;
value: string;
};
export type TimeseriesAnnotationLayer = BaseAnnotationLayer &
LineSourceAnnotationLayer & {
annotationType: 'TIME_SERIES';
annotationType: AnnotationType.Timeseries;
showMarkers?: boolean;
value: number;
};
@ -75,21 +108,57 @@ export type AnnotationLayer =
| TimeseriesAnnotationLayer;
export function isFormulaAnnotationLayer(layer: AnnotationLayer): layer is FormulaAnnotationLayer {
return layer.annotationType === 'FORMULA';
return layer.annotationType === AnnotationType.Formula;
}
export function isEventAnnotationLayer(layer: EventAnnotationLayer): layer is EventAnnotationLayer {
return layer.annotationType === 'EVENT';
export function isEventAnnotationLayer(layer: AnnotationLayer): layer is EventAnnotationLayer {
return layer.annotationType === AnnotationType.Event;
}
export function isIntervalAnnotationLayer(
layer: IntervalAnnotationLayer,
layer: AnnotationLayer,
): layer is IntervalAnnotationLayer {
return layer.annotationType === 'INTERVAL';
return layer.annotationType === AnnotationType.Interval;
}
export function isTimeseriesAnnotationLayer(
layer: AnnotationLayer,
): layer is TimeseriesAnnotationLayer {
return layer.annotationType === 'TIME_SERIES';
return layer.annotationType === AnnotationType.Timeseries;
}
export function isTableAnnotationLayer(layer: AnnotationLayer): layer is TableAnnotationLayer {
return layer.sourceType === AnnotationSourceType.Table;
}
export type RecordAnnotationResult = {
columns: string[];
records: DataRecord[];
};
export type TimeseriesAnnotationResult = [
{ key: string; values: { x: string | number; y?: number }[] },
];
export type AnnotationResult = RecordAnnotationResult | TimeseriesAnnotationResult;
export function isTimeseriesAnnotationResult(
result: AnnotationResult,
): result is TimeseriesAnnotationResult {
return Array.isArray(result);
}
export function isRecordAnnotationResult(
result: AnnotationResult,
): result is RecordAnnotationResult {
return 'columns' in result && 'records' in result;
}
export type AnnotationData = { [key: string]: AnnotationResult };
export type Annotation = {
descriptions?: string[];
intervalEnd?: string;
time?: string;
title?: string;
};

View File

@ -1,108 +0,0 @@
import {
isEventAnnotationLayer,
isFormulaAnnotationLayer,
isIntervalAnnotationLayer,
isTimeseriesAnnotationLayer,
} from '@superset-ui/core/src/query/types/AnnotationLayer';
describe('AnnotationLayer type guards', () => {
describe('isFormulaAnnotationLayer', () => {
it('should return true when it is the correct type', () => {
expect(
isFormulaAnnotationLayer({
annotationType: 'FORMULA',
name: 'My Formula',
value: 'sin(2*x)',
style: 'solid',
show: true,
}),
).toEqual(true);
});
it('should return false otherwise', () => {
expect(
isFormulaAnnotationLayer({
annotationType: 'EVENT',
name: 'My Event',
value: 1,
style: 'solid',
show: true,
}),
).toEqual(false);
});
});
describe('isEventAnnotationLayer', () => {
it('should return true when it is the correct type', () => {
expect(
isEventAnnotationLayer({
annotationType: 'EVENT',
name: 'My Event',
value: 1,
style: 'solid',
show: true,
}),
).toEqual(true);
});
it('should return false otherwise', () => {
expect(
isEventAnnotationLayer({
annotationType: 'FORMULA',
name: 'My Formula',
value: 'sin(2*x)',
style: 'solid',
show: true,
}),
).toEqual(false);
});
});
describe('isIntervalAnnotationLayer', () => {
it('should return true when it is the correct type', () => {
expect(
isIntervalAnnotationLayer({
annotationType: 'INTERVAL',
name: 'My Event',
value: 1,
style: 'solid',
show: true,
}),
).toEqual(true);
});
it('should return false otherwise', () => {
expect(
isEventAnnotationLayer({
annotationType: 'FORMULA',
name: 'My Formula',
value: 'sin(2*x)',
style: 'solid',
show: true,
}),
).toEqual(false);
});
});
describe('isTimeseriesAnnotationLayer', () => {
it('should return true when it is the correct type', () => {
expect(
isTimeseriesAnnotationLayer({
annotationType: 'TIME_SERIES',
name: 'My Event',
value: 1,
style: 'solid',
show: true,
}),
).toEqual(true);
});
it('should return false otherwise', () => {
expect(
isTimeseriesAnnotationLayer({
annotationType: 'FORMULA',
name: 'My Formula',
value: 'sin(2*x)',
style: 'solid',
show: true,
}),
).toEqual(false);
});
});
});

View File

@ -0,0 +1,149 @@
import {
AnnotationSourceType,
AnnotationStyle,
AnnotationType,
EventAnnotationLayer,
FormulaAnnotationLayer,
IntervalAnnotationLayer,
isEventAnnotationLayer,
isFormulaAnnotationLayer,
isIntervalAnnotationLayer,
isRecordAnnotationResult,
isTableAnnotationLayer,
isTimeseriesAnnotationLayer,
isTimeseriesAnnotationResult,
RecordAnnotationResult,
TableAnnotationLayer,
TimeseriesAnnotationLayer,
TimeseriesAnnotationResult,
} from '@superset-ui/core/src/query/types/AnnotationLayer';
describe('AnnotationLayer type guards', () => {
const formulaAnnotationLayer: FormulaAnnotationLayer = {
annotationType: AnnotationType.Formula,
name: 'My Formula',
value: 'sin(2*x)',
style: AnnotationStyle.Solid,
show: true,
};
const eventAnnotationLayer: EventAnnotationLayer = {
annotationType: AnnotationType.Event,
name: 'My Event',
value: 1,
style: AnnotationStyle.Solid,
show: true,
sourceType: AnnotationSourceType.Native,
};
const intervalAnnotationLayer: IntervalAnnotationLayer = {
annotationType: AnnotationType.Interval,
sourceType: AnnotationSourceType.Table,
name: 'My Event',
value: 1,
style: AnnotationStyle.Solid,
show: true,
};
const timeseriesAnnotationLayer: TimeseriesAnnotationLayer = {
annotationType: AnnotationType.Timeseries,
sourceType: AnnotationSourceType.Line,
name: 'My Event',
value: 1,
style: AnnotationStyle.Solid,
show: true,
};
const tableAnnotationLayer: TableAnnotationLayer = {
annotationType: AnnotationType.Interval,
sourceType: AnnotationSourceType.Table,
name: 'My Event',
value: 1,
style: AnnotationStyle.Solid,
show: true,
};
const timeseriesAnnotationResult: TimeseriesAnnotationResult = [
{
key: 'My Key',
values: [
{ x: -1000, y: 0 },
{ x: 0, y: 1000 },
{ x: 1000, y: 2000 },
],
},
];
const recordAnnotationResult: RecordAnnotationResult = {
columns: ['col1', 'col2'],
records: [
{ a: 1, b: 2 },
{ a: 2, b: 3 },
],
};
describe('isFormulaAnnotationLayer', () => {
it('should return true when it is the correct type', () => {
expect(isFormulaAnnotationLayer(formulaAnnotationLayer)).toEqual(true);
});
it('should return false otherwise', () => {
expect(isFormulaAnnotationLayer(eventAnnotationLayer)).toEqual(false);
expect(isFormulaAnnotationLayer(intervalAnnotationLayer)).toEqual(false);
expect(isFormulaAnnotationLayer(timeseriesAnnotationLayer)).toEqual(false);
});
});
describe('isEventAnnotationLayer', () => {
it('should return true when it is the correct type', () => {
expect(isEventAnnotationLayer(eventAnnotationLayer)).toEqual(true);
});
it('should return false otherwise', () => {
expect(isEventAnnotationLayer(formulaAnnotationLayer)).toEqual(false);
expect(isEventAnnotationLayer(intervalAnnotationLayer)).toEqual(false);
expect(isEventAnnotationLayer(timeseriesAnnotationLayer)).toEqual(false);
});
});
describe('isIntervalAnnotationLayer', () => {
it('should return true when it is the correct type', () => {
expect(isIntervalAnnotationLayer(intervalAnnotationLayer)).toEqual(true);
});
it('should return false otherwise', () => {
expect(isIntervalAnnotationLayer(formulaAnnotationLayer)).toEqual(false);
expect(isIntervalAnnotationLayer(eventAnnotationLayer)).toEqual(false);
expect(isIntervalAnnotationLayer(timeseriesAnnotationLayer)).toEqual(false);
});
});
describe('isTimeseriesAnnotationLayer', () => {
it('should return true when it is the correct type', () => {
expect(isTimeseriesAnnotationLayer(timeseriesAnnotationLayer)).toEqual(true);
});
it('should return false otherwise', () => {
expect(isTimeseriesAnnotationLayer(formulaAnnotationLayer)).toEqual(false);
expect(isTimeseriesAnnotationLayer(eventAnnotationLayer)).toEqual(false);
expect(isTimeseriesAnnotationLayer(intervalAnnotationLayer)).toEqual(false);
});
});
describe('isTableAnnotationLayer', () => {
it('should return true when it is the correct type', () => {
expect(isTableAnnotationLayer(tableAnnotationLayer)).toEqual(true);
});
it('should return false otherwise', () => {
expect(isTableAnnotationLayer(formulaAnnotationLayer)).toEqual(false);
});
});
describe('isTimeseriesAnnotationResult', () => {
it('should return true when it is the correct type', () => {
expect(isTimeseriesAnnotationResult(timeseriesAnnotationResult)).toEqual(true);
});
it('should return false otherwise', () => {
expect(isTimeseriesAnnotationResult(recordAnnotationResult)).toEqual(false);
});
});
describe('isRecordAnnotationResult', () => {
it('should return true when it is the correct type', () => {
expect(isRecordAnnotationResult(recordAnnotationResult)).toEqual(true);
});
it('should return false otherwise', () => {
expect(isRecordAnnotationResult(timeseriesAnnotationResult)).toEqual(false);
});
});
});

View File

@ -17,13 +17,9 @@
* under the License.
*/
import React from 'react';
import { EchartsTimeseriesProps } from './types';
import { EchartsProps } from '../types';
import Echart from '../components/Echart';
export default function EchartsTimeseries({
height,
width,
echartOptions,
}: EchartsTimeseriesProps) {
export default function EchartsTimeseries({ height, width, echartOptions }: EchartsProps) {
return <Echart height={height} width={width} echartOptions={echartOptions} />;
}

View File

@ -17,8 +17,36 @@
* under the License.
*/
import React from 'react';
import { t, legacyValidateInteger, legacyValidateNumber } from '@superset-ui/core';
import { legacyValidateInteger, legacyValidateNumber, t } from '@superset-ui/core';
import { ControlPanelConfig } from '@superset-ui/chart-controls';
import {
DEFAULT_FORM_DATA,
EchartsTimeseriesContributionType,
EchartsTimeseriesSeriesType,
} from './types';
const {
area,
annotationLayers,
contributionMode,
forecastEnabled,
forecastInterval,
forecastPeriods,
forecastSeasonalityDaily,
forecastSeasonalityWeekly,
forecastSeasonalityYearly,
logAxis,
markerEnabled,
markerSize,
minorSplitLine,
opacity,
rowLimit,
seriesType,
stack,
truncateYAxis,
yAxisBounds,
zoomable,
} = DEFAULT_FORM_DATA;
const config: ControlPanelConfig = {
controlPanelSections: [
@ -34,11 +62,11 @@ const config: ControlPanelConfig = {
config: {
type: 'SelectControl',
label: t('Contribution Mode'),
default: null,
default: contributionMode,
choices: [
[null, 'None'],
['row', 'Total'],
['column', 'Series'],
[EchartsTimeseriesContributionType.Row, 'Total'],
[EchartsTimeseriesContributionType.Column, 'Series'],
],
description: t('Calculate contribution per series or total'),
},
@ -70,10 +98,8 @@ const config: ControlPanelConfig = {
config: {
type: 'AnnotationLayerControl',
label: '',
default: [],
default: annotationLayers,
description: 'Annotation Layers',
renderTrigger: true,
tabOverride: 'data',
},
},
],
@ -90,7 +116,7 @@ const config: ControlPanelConfig = {
type: 'CheckboxControl',
label: t('Enable forecast'),
renderTrigger: false,
default: false,
default: forecastEnabled,
description: t('Enable forecasting'),
},
},
@ -102,7 +128,7 @@ const config: ControlPanelConfig = {
type: 'TextControl',
label: t('Forecast periods'),
validators: [legacyValidateInteger],
default: 10,
default: forecastPeriods,
description: t('How many periods into the future do we want to predict'),
},
},
@ -114,7 +140,7 @@ const config: ControlPanelConfig = {
type: 'TextControl',
label: t('Confidence interval'),
validators: [legacyValidateNumber],
default: 0.8,
default: forecastInterval,
description: t('Width of the confidence interval. Should be between 0 and 1'),
},
},
@ -129,7 +155,7 @@ const config: ControlPanelConfig = {
[true, 'Yes'],
[false, 'No'],
],
default: null,
default: forecastSeasonalityYearly,
description: t(
'Should yearly seasonality be applied. An integer value will specify Fourier order of seasonality.',
),
@ -148,7 +174,7 @@ const config: ControlPanelConfig = {
[true, 'Yes'],
[false, 'No'],
],
default: null,
default: forecastSeasonalityWeekly,
description: t(
'Should weekly seasonality be applied. An integer value will specify Fourier order of seasonality.',
),
@ -165,7 +191,7 @@ const config: ControlPanelConfig = {
[true, 'Yes'],
[false, 'No'],
],
default: null,
default: forecastSeasonalityDaily,
description: t(
'Should daily seasonality be applied. An integer value will specify Fourier order of seasonality.',
),
@ -186,15 +212,15 @@ const config: ControlPanelConfig = {
type: 'SelectControl',
label: t('Series Style'),
renderTrigger: true,
default: 'line',
default: seriesType,
choices: [
['line', 'Line'],
['scatter', 'Scatter'],
['smooth', 'Smooth Line'],
['bar', 'Bar'],
['start', 'Step - start'],
['middle', 'Step - middle'],
['end', 'Step - end'],
[EchartsTimeseriesSeriesType.Line, 'Line'],
[EchartsTimeseriesSeriesType.Scatter, 'Scatter'],
[EchartsTimeseriesSeriesType.Smooth, 'Smooth Line'],
[EchartsTimeseriesSeriesType.Bar, 'Bar'],
[EchartsTimeseriesSeriesType.Start, 'Step - start'],
[EchartsTimeseriesSeriesType.Middle, 'Step - middle'],
[EchartsTimeseriesSeriesType.End, 'Step - end'],
],
description: t('Series chart type (line, bar etc)'),
},
@ -207,7 +233,7 @@ const config: ControlPanelConfig = {
type: 'CheckboxControl',
label: t('Stack Lines'),
renderTrigger: true,
default: false,
default: stack,
description: t('Stack series on top of each other'),
},
},
@ -219,7 +245,7 @@ const config: ControlPanelConfig = {
type: 'CheckboxControl',
label: t('Area Chart'),
renderTrigger: true,
default: false,
default: area,
description: t('Draw area under curves. Only applicable for line types.'),
},
},
@ -232,7 +258,7 @@ const config: ControlPanelConfig = {
min: 0,
max: 1,
step: 0.1,
default: 0.2,
default: opacity,
description: t('Opacity of Area Chart. Also applies to confidence band.'),
},
},
@ -244,7 +270,7 @@ const config: ControlPanelConfig = {
type: 'CheckboxControl',
label: t('Marker'),
renderTrigger: true,
default: false,
default: markerEnabled,
description: t('Draw a marker on data points. Only applicable for line types.'),
},
},
@ -256,7 +282,7 @@ const config: ControlPanelConfig = {
renderTrigger: true,
min: 0,
max: 100,
default: 6,
default: markerSize,
description: t('Size of marker. Also applies to forecast observations.'),
},
},
@ -267,7 +293,7 @@ const config: ControlPanelConfig = {
config: {
type: 'CheckboxControl',
label: t('Data Zoom'),
default: false,
default: zoomable,
renderTrigger: true,
description: t('Enable data zooming controls'),
},
@ -283,7 +309,7 @@ const config: ControlPanelConfig = {
type: 'CheckboxControl',
label: t('Logarithmic y-axis'),
renderTrigger: true,
default: false,
default: logAxis,
description: t('Logarithmic y-axis'),
},
},
@ -293,7 +319,7 @@ const config: ControlPanelConfig = {
type: 'CheckboxControl',
label: t('Minor Split Line'),
renderTrigger: true,
default: false,
default: minorSplitLine,
description: t('Draw split lines for minor y-axis ticks'),
},
},
@ -304,7 +330,7 @@ const config: ControlPanelConfig = {
config: {
type: 'CheckboxControl',
label: t('Truncate Y Axis'),
default: true,
default: truncateYAxis,
renderTrigger: true,
description: t(
'Truncate Y Axis. Can be overridden by specifying a min or max bound.',
@ -319,7 +345,7 @@ const config: ControlPanelConfig = {
type: 'BoundsControl',
label: t('Y Axis Bounds'),
renderTrigger: true,
default: [undefined, undefined],
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 ' +
@ -344,7 +370,7 @@ const config: ControlPanelConfig = {
},
controlOverrides: {
row_limit: {
default: 10000,
default: rowLimit,
},
},
};

View File

@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { t, ChartMetadata, ChartPlugin } from '@superset-ui/core';
import { t, ChartMetadata, ChartPlugin, AnnotationType } from '@superset-ui/core';
import buildQuery from './buildQuery';
import controlPanel from './controlPanel';
import transformProps from './transformProps';
@ -41,7 +41,12 @@ export default class EchartsTimeseriesChartPlugin extends ChartPlugin {
metadata: new ChartMetadata({
credits: ['https://echarts.apache.org'],
description: 'Time-series (Apache ECharts)',
supportedAnnotationTypes: ['FORMULA'],
supportedAnnotationTypes: [
AnnotationType.Event,
AnnotationType.Formula,
AnnotationType.Interval,
AnnotationType.Timeseries,
],
name: t('Time-series Chart'),
thumbnail,
}),

View File

@ -16,20 +16,25 @@
* specific language governing permissions and limitations
* under the License.
*/
/* eslint-disable camelcase */
import {
AnnotationData,
AnnotationLayer,
isFormulaAnnotationLayer,
ChartProps,
CategoricalColorNamespace,
ChartProps,
getNumberFormatter,
isEventAnnotationLayer,
isFormulaAnnotationLayer,
isIntervalAnnotationLayer,
isTimeseriesAnnotationLayer,
smartDateVerboseFormatter,
TimeseriesDataRecord,
} from '@superset-ui/core';
import { EchartsTimeseriesProps } from './types';
import { ForecastSeriesEnum } from '../types';
import { DEFAULT_FORM_DATA, EchartsTimeseriesFormData } from './types';
import { EchartsProps, ForecastSeriesEnum } from '../types';
import { parseYAxisBound } from '../utils/controls';
import { extractTimeseriesSeries } from '../utils/series';
import { evalFormula, parseAnnotationOpacity } from '../utils/annotation';
import { extractAnnotationLabels } from '../utils/annotation';
import {
extractForecastSeriesContext,
extractProphetValuesFromTooltipParams,
@ -37,116 +42,70 @@ import {
rebaseTimeseriesDatum,
} from '../utils/prophet';
import { defaultGrid, defaultTooltip, defaultYAxis } from '../defaults';
import {
transformEventAnnotation,
transformFormulaAnnotation,
transformIntervalAnnotation,
transformSeries,
transformTimeseriesAnnotation,
} from './transformers';
export default function transformProps(chartProps: ChartProps): EchartsTimeseriesProps {
export default function transformProps(chartProps: ChartProps): EchartsProps {
const { width, height, formData, queryData } = chartProps;
const { data = [] }: { data?: TimeseriesDataRecord[] } = queryData;
const {
annotationLayers = [],
area,
annotation_data: annotationData = {},
data = [],
}: { annotation_data?: AnnotationData; data?: TimeseriesDataRecord[] } = queryData;
const {
annotationLayers,
colorScheme,
contributionMode,
forecastEnabled,
seriesType,
logAxis,
opacity,
stack,
markerEnabled,
markerSize,
minorSplitLine,
truncateYAxis,
yAxisFormat,
yAxisBounds,
zoomable,
} = formData;
}: EchartsTimeseriesFormData = { ...DEFAULT_FORM_DATA, ...formData };
const colorFn = CategoricalColorNamespace.getScale(colorScheme as string);
const colorScale = CategoricalColorNamespace.getScale(colorScheme as string);
const rebasedData = rebaseTimeseriesDatum(data);
const rawSeries = extractTimeseriesSeries(rebasedData);
const series: echarts.EChartOption.Series[] = [];
const formatter = getNumberFormatter(contributionMode ? ',.0%' : yAxisFormat);
rawSeries.forEach(entry => {
const forecastSeries = extractForecastSeriesContext(entry.name || '');
const isConfidenceBand =
forecastSeries.type === ForecastSeriesEnum.ForecastLower ||
forecastSeries.type === ForecastSeriesEnum.ForecastUpper;
const isObservation = forecastSeries.type === ForecastSeriesEnum.Observation;
const isTrend = forecastSeries.type === ForecastSeriesEnum.ForecastTrend;
let stackId;
if (isConfidenceBand) {
stackId = forecastSeries.name;
} else if (stack && isObservation) {
// the suffix of the observation series is '' (falsy), which disables
// stacking. Therefore we need to set something that is truthy.
stackId = 'obs';
} else if (stack && isTrend) {
stackId = forecastSeries.type;
}
let plotType;
if (!isConfidenceBand && (seriesType === 'scatter' || (forecastEnabled && isObservation))) {
plotType = 'scatter';
} else if (isConfidenceBand) {
plotType = 'line';
} else {
plotType = seriesType === 'bar' ? 'bar' : 'line';
}
const lineStyle = isConfidenceBand ? { opacity: 0 } : {};
if (!((stack || area) && isConfidenceBand))
series.push({
...entry,
id: entry.name,
name: forecastSeries.name,
itemStyle: {
color: colorFn(forecastSeries.name),
},
type: plotType,
// @ts-ignore
smooth: seriesType === 'smooth',
step: ['start', 'middle', 'end'].includes(seriesType as string) ? seriesType : undefined,
stack: stackId,
lineStyle,
areaStyle: {
opacity: forecastSeries.type === ForecastSeriesEnum.ForecastUpper || area ? opacity : 0,
},
symbolSize:
!isConfidenceBand &&
(plotType === 'scatter' || (forecastEnabled && isObservation) || markerEnabled)
? markerSize
: 0,
});
const transformedSeries = transformSeries(
entry,
formData as EchartsTimeseriesFormData,
colorScale,
);
if (transformedSeries) series.push(transformedSeries);
});
annotationLayers.forEach((layer: AnnotationLayer) => {
const {
name,
color,
opacity: annotationOpacity,
width: annotationWidth,
show: annotationShow,
style,
} = layer;
if (annotationShow && isFormulaAnnotationLayer(layer)) {
series.push({
name,
id: name,
itemStyle: {
color: color || colorFn(name),
},
lineStyle: {
opacity: parseAnnotationOpacity(annotationOpacity),
type: style,
width: annotationWidth,
},
type: 'line',
smooth: true,
// @ts-ignore
data: evalFormula(layer, data),
symbolSize: 0,
});
}
});
annotationLayers
.filter((layer: AnnotationLayer) => layer.show)
.forEach((layer: AnnotationLayer) => {
if (isFormulaAnnotationLayer(layer))
series.push(transformFormulaAnnotation(layer, data, colorScale));
else if (isIntervalAnnotationLayer(layer)) {
series.push(...transformIntervalAnnotation(layer, data, annotationData, colorScale));
} else if (isEventAnnotationLayer(layer)) {
series.push(...transformEventAnnotation(layer, data, annotationData, colorScale));
} else if (isTimeseriesAnnotationLayer(layer)) {
series.push(
...transformTimeseriesAnnotation(
layer,
formData as EchartsTimeseriesFormData,
data,
annotationData,
),
);
}
});
// yAxisBounds need to be parsed to replace incompatible values with undefined
let [min, max] = (yAxisBounds || []).map(parseYAxisBound);
@ -204,7 +163,7 @@ export default function transformProps(chartProps: ChartProps): EchartsTimeserie
extractForecastSeriesContext(entry.name || '').type === ForecastSeriesEnum.Observation,
)
.map(entry => entry.name || '')
.concat(annotationLayers.map((layer: AnnotationLayer) => layer.name)),
.concat(extractAnnotationLabels(annotationLayers, annotationData)),
right: zoomable ? 80 : 'auto',
},
series,
@ -233,18 +192,8 @@ export default function transformProps(chartProps: ChartProps): EchartsTimeserie
};
return {
area,
colorScheme,
contributionMode,
// @ts-ignore
echartOptions,
seriesType,
logAxis,
opacity,
stack,
markerEnabled,
markerSize,
minorSplitLine,
width,
height,
};

View File

@ -0,0 +1,272 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import {
AnnotationData,
AnnotationOpacity,
CategoricalColorScale,
EventAnnotationLayer,
FormulaAnnotationLayer,
IntervalAnnotationLayer,
isTimeseriesAnnotationResult,
TimeseriesAnnotationLayer,
TimeseriesDataRecord,
} from '@superset-ui/core';
import { extractForecastSeriesContext } from '../utils/prophet';
import { ForecastSeriesEnum } from '../types';
import { DEFAULT_FORM_DATA, EchartsTimeseriesFormData } from './types';
import {
evalFormula,
extractRecordAnnotations,
formatAnnotationLabel,
parseAnnotationOpacity,
} from '../utils/annotation';
export function transformSeries(
series: echarts.EChartOption.Series,
formData: EchartsTimeseriesFormData,
colorScale: CategoricalColorScale,
): echarts.EChartOption.Series | undefined {
const { name } = series;
const { area, forecastEnabled, markerEnabled, markerSize, opacity, seriesType, stack } = {
...DEFAULT_FORM_DATA,
...formData,
};
const forecastSeries = extractForecastSeriesContext(name || '');
const isConfidenceBand =
forecastSeries.type === ForecastSeriesEnum.ForecastLower ||
forecastSeries.type === ForecastSeriesEnum.ForecastUpper;
// don't create a series if doing a stack or area chart and the result
// is a confidence band
if ((stack || area) && isConfidenceBand) return undefined;
const isObservation = forecastSeries.type === ForecastSeriesEnum.Observation;
const isTrend = forecastSeries.type === ForecastSeriesEnum.ForecastTrend;
let stackId;
if (isConfidenceBand) {
stackId = forecastSeries.name;
} else if (stack && isObservation) {
// the suffix of the observation series is '' (falsy), which disables
// stacking. Therefore we need to set something that is truthy.
stackId = 'obs';
} else if (stack && isTrend) {
stackId = forecastSeries.type;
}
let plotType;
if (!isConfidenceBand && (seriesType === 'scatter' || (forecastEnabled && isObservation))) {
plotType = 'scatter';
} else if (isConfidenceBand) {
plotType = 'line';
} else {
plotType = seriesType === 'bar' ? 'bar' : 'line';
}
const lineStyle = isConfidenceBand ? { opacity: 0 } : {};
return {
...series,
name: forecastSeries.name,
itemStyle: {
color: colorScale(forecastSeries.name),
},
type: plotType,
// @ts-ignore
smooth: seriesType === 'smooth',
step: ['start', 'middle', 'end'].includes(seriesType as string) ? seriesType : undefined,
stack: stackId,
lineStyle,
areaStyle: {
opacity: forecastSeries.type === ForecastSeriesEnum.ForecastUpper || area ? opacity : 0,
},
symbolSize:
!isConfidenceBand &&
(plotType === 'scatter' || (forecastEnabled && isObservation) || markerEnabled)
? markerSize
: 0,
};
}
export function transformFormulaAnnotation(
layer: FormulaAnnotationLayer,
data: TimeseriesDataRecord[],
colorScale: CategoricalColorScale,
): echarts.EChartOption.Series {
const { name, color, opacity, width, style } = layer;
return {
name,
id: name,
itemStyle: {
color: color || colorScale(name),
},
lineStyle: {
opacity: parseAnnotationOpacity(opacity),
type: style,
width,
},
type: 'line',
smooth: true,
// @ts-ignore
data: evalFormula(layer, data),
symbolSize: 0,
z: 0,
};
}
export function transformIntervalAnnotation(
layer: IntervalAnnotationLayer,
data: TimeseriesDataRecord[],
annotationData: AnnotationData,
colorScale: CategoricalColorScale,
): echarts.EChartOption.Series[] {
const series: echarts.EChartOption.Series[] = [];
const annotations = extractRecordAnnotations(layer, annotationData);
annotations.forEach(annotation => {
const { name, color, opacity } = layer;
const { descriptions, intervalEnd, time, title } = annotation;
const label = formatAnnotationLabel(name, title, descriptions);
const intervalData = [
[
{
name: label,
xAxis: time,
},
{
xAxis: intervalEnd,
},
],
];
series.push({
id: `Interval - ${label}`,
type: 'line',
animation: false,
markArea: {
silent: false,
itemStyle: {
color: color || colorScale(name),
opacity: parseAnnotationOpacity(opacity || AnnotationOpacity.Medium),
emphasis: {
opacity: 0.8,
},
},
label: {
show: false,
color: '#000000',
emphasis: {
fontWeight: 'bold',
show: true,
position: 'insideTop',
verticalAlign: 'top',
backgroundColor: '#ffffff',
},
},
// @ts-ignore
data: intervalData,
},
});
});
return series;
}
export function transformEventAnnotation(
layer: EventAnnotationLayer,
data: TimeseriesDataRecord[],
annotationData: AnnotationData,
colorScale: CategoricalColorScale,
): echarts.EChartOption.Series[] {
const series: echarts.EChartOption.Series[] = [];
const annotations = extractRecordAnnotations(layer, annotationData);
annotations.forEach(annotation => {
const { name, color, opacity, style, width } = layer;
const { descriptions, time, title } = annotation;
const label = formatAnnotationLabel(name, title, descriptions);
const eventData = [
{
name: label,
xAxis: time,
},
];
series.push({
id: `Event - ${label}`,
type: 'line',
animation: false,
markLine: {
silent: false,
symbol: 'none',
lineStyle: {
width,
type: style,
color: color || colorScale(name),
opacity: parseAnnotationOpacity(opacity),
emphasis: {
width: width ? width + 1 : width,
opacity: 1,
},
},
label: {
show: false,
color: '#000000',
position: 'insideEndTop',
emphasis: {
// @ts-ignore
formatter: params => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return params.name;
},
// @ts-ignore
fontWeight: 'bold',
show: true,
backgroundColor: '#ffffff',
},
},
// @ts-ignore
data: eventData,
},
});
});
return series;
}
export function transformTimeseriesAnnotation(
layer: TimeseriesAnnotationLayer,
formData: EchartsTimeseriesFormData,
data: TimeseriesDataRecord[],
annotationData: AnnotationData,
): echarts.EChartOption.Series[] {
const series: echarts.EChartOption.Series[] = [];
const { markerSize } = formData;
const { hideLine, name, opacity, showMarkers, style, width } = layer;
const result = annotationData[name];
if (isTimeseriesAnnotationResult(result)) {
result.forEach(annotation => {
const { key, values } = annotation;
series.push({
type: 'line',
id: key,
name: key,
data: values.map(row => [row.x, row.y] as [number | string, number]),
symbolSize: showMarkers ? markerSize : 0,
lineStyle: {
opacity: parseAnnotationOpacity(opacity),
type: style,
width: hideLine ? 0 : width,
},
});
});
}
return series;
}

View File

@ -16,38 +16,68 @@
* specific language governing permissions and limitations
* under the License.
*/
import { DataRecordValue } from '@superset-ui/core';
import { EchartsProps } from '../types';
import { AnnotationLayer } from '@superset-ui/core';
export type TimestampType = string | number | Date;
export enum EchartsTimeseriesContributionType {
Row = 'row',
Column = 'column',
}
export type EchartsBaseTimeseriesSeries = {
name: string;
data: [Date, DataRecordValue][];
};
export enum EchartsTimeseriesSeriesType {
Line = 'line',
Scatter = 'scatter',
Smooth = 'smooth',
Bar = 'bar',
Start = 'start',
Middle = 'middle',
End = 'end',
}
export type EchartsTimeseriesSeries = EchartsBaseTimeseriesSeries & {
color: string;
stack?: string;
type: 'bar' | 'line';
smooth: boolean;
step?: 'start' | 'middle' | 'end';
areaStyle: {
opacity: number;
};
symbolSize: number;
};
export type EchartsTimeseriesProps = EchartsProps & {
area: number;
colorScheme: string;
contributionMode?: string;
zoomable?: boolean;
seriesType: string;
export type EchartsTimeseriesFormData = {
annotationLayers: AnnotationLayer[];
area: boolean;
colorScheme?: string;
contributionMode?: EchartsTimeseriesContributionType;
forecastEnabled: boolean;
forecastPeriods: number;
forecastInterval: number;
forecastSeasonalityDaily: null;
forecastSeasonalityWeekly: null;
forecastSeasonalityYearly: null;
logAxis: boolean;
stack: boolean;
markerEnabled: boolean;
markerSize: number;
minorSplitLine: boolean;
opacity: number;
orderDesc: boolean;
rowLimit: number;
seriesType: EchartsTimeseriesSeriesType;
stack: boolean;
truncateYAxis: boolean;
yAxisFormat?: string;
yAxisBounds: [number | undefined | null, number | undefined | null];
zoomable: boolean;
};
export const DEFAULT_FORM_DATA: EchartsTimeseriesFormData = {
annotationLayers: [],
area: false,
forecastEnabled: false,
forecastInterval: 0.8,
forecastPeriods: 10,
forecastSeasonalityDaily: null,
forecastSeasonalityWeekly: null,
forecastSeasonalityYearly: null,
seriesType: EchartsTimeseriesSeriesType.Line,
logAxis: false,
opacity: 0.2,
orderDesc: true,
stack: false,
markerEnabled: false,
markerSize: 6,
minorSplitLine: false,
rowLimit: 10000,
truncateYAxis: true,
yAxisBounds: [null, null],
zoomable: false,
};

View File

@ -17,7 +17,18 @@
* specific language governing permissions and limitations
* under the License.
*/
import { AnnotationOpacity, FormulaAnnotationLayer, TimeseriesDataRecord } from '@superset-ui/core';
import {
Annotation,
AnnotationData,
AnnotationLayer,
AnnotationOpacity,
AnnotationType,
FormulaAnnotationLayer,
isRecordAnnotationResult,
isTableAnnotationLayer,
isTimeseriesAnnotationResult,
TimeseriesDataRecord,
} from '@superset-ui/core';
import { parse as mathjsParse } from 'mathjs';
export function evalFormula(
@ -34,13 +45,74 @@ export function evalFormula(
export function parseAnnotationOpacity(opacity?: AnnotationOpacity): number {
switch (opacity) {
case 'opacityLow':
case AnnotationOpacity.Low:
return 0.2;
case 'opacityMedium':
case AnnotationOpacity.Medium:
return 0.5;
case 'opacityHigh':
case AnnotationOpacity.High:
return 0.8;
default:
return 1;
}
}
const NATIVE_COLUMN_NAMES = {
descriptionColumns: ['long_descr'],
intervalEndColumn: 'end_dttm',
timeColumn: 'start_dttm',
titleColumn: 'short_descr',
};
export function extractRecordAnnotations(
annotationLayer: AnnotationLayer,
annotationData: AnnotationData,
): Annotation[] {
const { name } = annotationLayer;
const result = annotationData[name];
if (isRecordAnnotationResult(result)) {
const { records } = result;
const {
descriptionColumns = [],
intervalEndColumn = '',
timeColumn = '',
titleColumn = '',
} = isTableAnnotationLayer(annotationLayer) ? annotationLayer : NATIVE_COLUMN_NAMES;
return records.map(record => ({
descriptions: descriptionColumns.map(column => (record[column] || '') as string) as string[],
intervalEnd: (record[intervalEndColumn] || '') as string,
time: (record[timeColumn] || '') as string,
title: (record[titleColumn] || '') as string,
}));
}
throw new Error('Please rerun the query.');
}
export function formatAnnotationLabel(
name?: string,
title?: string,
descriptions: string[] = [],
): string {
const labels: string[] = [];
const titleLabels: string[] = [];
const filteredDescriptions = descriptions.filter(description => !!description);
if (name) titleLabels.push(name);
if (title) titleLabels.push(title);
if (titleLabels.length > 0) labels.push(titleLabels.join(' - '));
if (filteredDescriptions.length > 0) labels.push(filteredDescriptions.join('\n'));
return labels.join('\n\n');
}
export function extractAnnotationLabels(layers: AnnotationLayer[], data: AnnotationData): string[] {
const formulaAnnotationLabels = layers
.filter(anno => anno.annotationType === AnnotationType.Formula && anno.show)
.map(anno => anno.name);
const timeseriesAnnotationLabels = layers
.filter(anno => anno.annotationType === AnnotationType.Timeseries && anno.show)
.flatMap(anno => {
const result = data[anno.name];
return isTimeseriesAnnotationResult(result) ? result.map(annoSeries => annoSeries.key) : [];
});
return formulaAnnotationLabels.concat(timeseriesAnnotationLabels);
}

View File

@ -38,6 +38,7 @@ export function extractTimeseriesSeries(
return Object.keys(rows[0])
.filter(key => key !== '__timestamp')
.map(key => ({
id: key,
name: key,
// @ts-ignore
// eslint-disable-next-line @typescript-eslint/no-unsafe-return

View File

@ -17,7 +17,13 @@
* under the License.
*/
import 'babel-polyfill';
import { ChartProps, FormulaAnnotationLayer } from '@superset-ui/core';
import {
ChartProps,
EventAnnotationLayer,
FormulaAnnotationLayer,
IntervalAnnotationLayer,
TimeseriesAnnotationLayer,
} from '@superset-ui/core';
import transformProps from '../../src/Timeseries/transformProps';
describe('EchartsTimeseries tranformProps', () => {
@ -28,19 +34,21 @@ describe('EchartsTimeseries tranformProps', () => {
metric: 'sum__num',
groupby: ['foo', 'bar'],
};
const chartProps = new ChartProps({
const queryData = {
data: [
{ 'San Francisco': 1, 'New York': 2, __timestamp: 599616000000 },
{ 'San Francisco': 3, 'New York': 4, __timestamp: 599916000000 },
],
};
const chartPropsConfig = {
formData,
width: 800,
height: 600,
queryData: {
data: [
{ 'San Francisco': 1, 'New York': 2, __timestamp: 599616000000 },
{ 'San Francisco': 3, 'New York': 4, __timestamp: 599916000000 },
],
},
});
queryData,
};
it('should tranform chart props for viz', () => {
const chartProps = new ChartProps(chartPropsConfig);
expect(transformProps(chartProps)).toEqual(
expect.objectContaining({
width: 800,
@ -70,7 +78,7 @@ describe('EchartsTimeseries tranformProps', () => {
);
});
it('should add a formula to viz', () => {
it('should add a formula annotation to viz', () => {
const formula: FormulaAnnotationLayer = {
name: 'My Formula',
annotationType: 'FORMULA',
@ -78,21 +86,14 @@ describe('EchartsTimeseries tranformProps', () => {
style: 'solid',
show: true,
};
const formulaChartProps = new ChartProps({
const chartProps = new ChartProps({
...chartPropsConfig,
formData: {
...formData,
annotationLayers: [formula],
},
width: 800,
height: 600,
queryData: {
data: [
{ 'San Francisco': 1, 'New York': 2, __timestamp: 599616000000 },
{ 'San Francisco': 3, 'New York': 4, __timestamp: 599916000000 },
],
},
});
expect(transformProps(formulaChartProps)).toEqual(
expect(transformProps(chartProps)).toEqual(
expect.objectContaining({
width: 800,
height: 600,
@ -127,4 +128,110 @@ describe('EchartsTimeseries tranformProps', () => {
}),
);
});
it('should add an interval, event and timeseries annotation to viz', () => {
const event: EventAnnotationLayer = {
annotationType: 'EVENT',
name: 'My Event',
show: true,
sourceType: 'NATIVE',
style: 'solid',
value: 1,
};
const interval: IntervalAnnotationLayer = {
annotationType: 'INTERVAL',
name: 'My Interval',
show: true,
sourceType: 'table',
titleColumn: '',
timeColumn: 'start',
intervalEndColumn: '',
descriptionColumns: [],
style: 'dashed',
value: 2,
};
const timeseries: TimeseriesAnnotationLayer = {
annotationType: 'TIME_SERIES',
name: 'My Timeseries',
show: true,
sourceType: 'line',
style: 'solid',
titleColumn: '',
value: 3,
};
const chartProps = new ChartProps({
...chartPropsConfig,
formData: {
...formData,
annotationLayers: [event, interval, timeseries],
},
queryData: {
...queryData,
annotation_data: {
'My Event': {
columns: ['start_dttm', 'end_dttm', 'short_descr', 'long_descr', 'json_metadata'],
records: [
{
start_dttm: 0,
end_dttm: 1000,
short_descr: '',
long_descr: '',
json_metadata: null,
},
],
},
'My Interval': {
columns: ['start', 'end', 'title'],
records: [
{
start: 2000,
end: 3000,
title: 'My Title',
},
],
},
'My Timeseries': [
{
key: 'My Line',
values: [
{
x: 10000,
y: 11000,
},
{
x: 20000,
y: 21000,
},
],
},
],
},
},
});
expect(transformProps(chartProps)).toEqual(
expect.objectContaining({
echartOptions: expect.objectContaining({
legend: expect.objectContaining({
data: ['San Francisco', 'New York', 'My Line'],
}),
series: expect.arrayContaining([
expect.objectContaining({
type: 'line',
id: 'My Line',
}),
expect.objectContaining({
type: 'line',
id: 'Event - My Event',
}),
expect.objectContaining({
type: 'line',
id: 'Interval - My Interval',
}),
]),
}),
}),
);
});
});

View File

@ -13,7 +13,10 @@
],
"references": [
{
"path": ".."
"path": "../../packages/superset-ui-chart-controls"
},
{
"path": "../../packages/superset-ui-core"
}
]
}
}

View File

@ -16,7 +16,40 @@
* specific language governing permissions and limitations
* under the License.
*/
import { parseAnnotationOpacity } from '../../src/utils/annotation';
import { AnnotationLayer, AnnotationResult } from '@superset-ui/core';
import {
extractAnnotationLabels,
formatAnnotationLabel,
parseAnnotationOpacity,
} from '../../src/utils/annotation';
describe('formatAnnotationLabel', () => {
it('should handle default cases properly', () => {
expect(formatAnnotationLabel('name')).toEqual('name');
expect(formatAnnotationLabel('name', 'title')).toEqual('name - title');
expect(formatAnnotationLabel('name', 'title', ['description'])).toEqual(
'name - title\n\ndescription',
);
});
it('should handle missing cases properly', () => {
expect(formatAnnotationLabel()).toEqual('');
expect(formatAnnotationLabel(undefined, 'title')).toEqual('title');
expect(formatAnnotationLabel('name', undefined, ['description'])).toEqual(
'name\n\ndescription',
);
expect(formatAnnotationLabel(undefined, undefined, ['description'])).toEqual('description');
});
it('should handle multiple descriptions properly', () => {
expect(formatAnnotationLabel('name', 'title', ['description 1', 'description 2'])).toEqual(
'name - title\n\ndescription 1\ndescription 2',
);
expect(formatAnnotationLabel(undefined, undefined, ['description 1', 'description 2'])).toEqual(
'description 1\ndescription 2',
);
});
});
describe('extractForecastSeriesContext', () => {
it('should extract the correct series name and type', () => {
@ -27,3 +60,60 @@ describe('extractForecastSeriesContext', () => {
expect(parseAnnotationOpacity(undefined)).toEqual(1);
});
});
describe('extractAnnotationLabels', () => {
it('should extract all annotations that can be added to the legend', () => {
const layers: AnnotationLayer[] = [
{
annotationType: 'FORMULA',
name: 'My Formula',
show: true,
style: 'solid',
value: 'sin(x)',
},
{
annotationType: 'FORMULA',
name: 'My Hidden Formula',
show: false,
style: 'solid',
value: 'sin(2x)',
},
{
annotationType: 'INTERVAL',
name: 'My Interval',
sourceType: 'table',
show: true,
style: 'solid',
value: 1,
},
{
annotationType: 'TIME_SERIES',
name: 'My Line',
show: true,
style: 'dashed',
sourceType: 'line',
value: 1,
},
{
annotationType: 'TIME_SERIES',
name: 'My Hidden Line',
show: false,
style: 'dashed',
sourceType: 'line',
value: 1,
},
];
const results: AnnotationResult = {
'My Interval': {
columns: ['col'],
records: [{ col: 1 }],
},
'My Line': [
{ key: 'Line 1', values: [] },
{ key: 'Line 2', values: [] },
],
};
expect(extractAnnotationLabels(layers, results)).toEqual(['My Formula', 'Line 1', 'Line 2']);
});
});

View File

@ -44,6 +44,7 @@ describe('extractTimeseriesSeries', () => {
];
expect(extractTimeseriesSeries(data)).toEqual([
{
id: 'Hulk',
name: 'Hulk',
data: [
[new Date('2000-01-01'), null],
@ -52,6 +53,7 @@ describe('extractTimeseriesSeries', () => {
],
},
{
id: 'abc',
name: 'abc',
data: [
[new Date('2000-01-01'), 2],