mirror of
https://github.com/apache/superset.git
synced 2024-09-19 12:09:42 -04:00
feat(plugin-chart-echarts): implement event interval and timeseries annotations (#828)
This commit is contained in:
parent
5f5e275279
commit
8bfaf4eb0e
@ -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;
|
||||
};
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
@ -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} />;
|
||||
}
|
||||
|
@ -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,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
@ -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,
|
||||
}),
|
||||
|
@ -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,
|
||||
};
|
||||
|
@ -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;
|
||||
}
|
@ -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,
|
||||
};
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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',
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
@ -13,7 +13,10 @@
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": ".."
|
||||
"path": "../../packages/superset-ui-chart-controls"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/superset-ui-core"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
@ -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']);
|
||||
});
|
||||
});
|
||||
|
@ -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],
|
||||
|
Loading…
Reference in New Issue
Block a user