feat(plugin-chart-echarts): add support for formula annotations (#817)

* feat(plugin-chart-echarts): add support for formula annotations

* address comments
This commit is contained in:
Ville Brofeldt 2020-10-29 09:21:10 +02:00 committed by Yongjie Zhao
parent 85f9cdad0a
commit 06ef549122
9 changed files with 246 additions and 12 deletions

View File

@ -1,8 +1,11 @@
/* eslint camelcase: 0 */
export type AnnotationOpacity = '' | 'opacityLow' | 'opacityMedium' | 'opacityHigh';
type BaseAnnotationLayer = {
color?: string | null;
name: string;
opacity?: '' | 'opacityLow' | 'opacityMedium' | 'opacityHigh';
opacity?: AnnotationOpacity;
show: boolean;
style: 'dashed' | 'dotted' | 'solid' | 'longDashed';
width?: number;

View File

@ -29,7 +29,9 @@
"@superset-ui/chart-controls": "0.15.10",
"@superset-ui/core": "0.15.10",
"@types/echarts": "^4.6.3",
"echarts": "^4.9.0"
"@types/mathjs": "^6.0.6",
"echarts": "^4.9.0",
"mathjs": "^7.5.1"
},
"peerDependencies": {
"react": "^16.13.1"

View File

@ -60,9 +60,28 @@ const config: ControlPanelConfig = {
['row_limit', null],
],
},
{
label: t('Annotations and Layers'),
expanded: false,
controlSetRows: [
[
{
name: 'annotation_layers',
config: {
type: 'AnnotationLayerControl',
label: '',
default: [],
description: 'Annotation Layers',
renderTrigger: true,
tabOverride: 'data',
},
},
],
],
},
{
label: t('Predictive Analytics'),
expanded: true,
expanded: false,
controlSetRows: [
[
{

View File

@ -41,6 +41,7 @@ export default class EchartsTimeseriesChartPlugin extends ChartPlugin {
metadata: new ChartMetadata({
credits: ['https://echarts.apache.org'],
description: 'Time-series (Apache ECharts)',
supportedAnnotationTypes: ['FORMULA'],
name: t('Time-series Chart'),
thumbnail,
}),

View File

@ -17,15 +17,19 @@
* under the License.
*/
import {
AnnotationLayer,
isFormulaAnnotationLayer,
ChartProps,
CategoricalColorNamespace,
getNumberFormatter,
smartDateVerboseFormatter,
TimeseriesDataRecord,
} from '@superset-ui/core';
import { EchartsTimeseriesProps } from './types';
import { ForecastSeriesEnum } from '../types';
import { parseYAxisBound } from '../utils/controls';
import { extractTimeseriesSeries } from '../utils/series';
import { evalFormula, parseAnnotationOpacity } from '../utils/annotation';
import {
extractForecastSeriesContext,
extractProphetValuesFromTooltipParams,
@ -36,7 +40,9 @@ import { defaultGrid, defaultTooltip, defaultYAxis } from '../defaults';
export default function transformProps(chartProps: ChartProps): EchartsTimeseriesProps {
const { width, height, formData, queryData } = chartProps;
const { data = [] }: { data?: TimeseriesDataRecord[] } = queryData;
const {
annotationLayers = [],
area,
colorScheme,
contributionMode,
@ -55,10 +61,8 @@ export default function transformProps(chartProps: ChartProps): EchartsTimeserie
} = formData;
const colorFn = CategoricalColorNamespace.getScale(colorScheme as string);
const rebasedData = rebaseTimeseriesDatum(queryData.data || []);
const rebasedData = rebaseTimeseriesDatum(data);
const rawSeries = extractTimeseriesSeries(rebasedData);
const series: echarts.EChartOption.Series[] = [];
const formatter = getNumberFormatter(contributionMode ? ',.0%' : yAxisFormat);
@ -114,6 +118,36 @@ export default function transformProps(chartProps: ChartProps): EchartsTimeserie
});
});
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,
});
}
});
// yAxisBounds need to be parsed to replace incompatible values with undefined
let [min, max] = (yAxisBounds || []).map(parseYAxisBound);
@ -169,7 +203,8 @@ export default function transformProps(chartProps: ChartProps): EchartsTimeserie
entry =>
extractForecastSeriesContext(entry.name || '').type === ForecastSeriesEnum.Observation,
)
.map(entry => entry.name || ''),
.map(entry => entry.name || '')
.concat(annotationLayers.map((layer: AnnotationLayer) => layer.name)),
right: zoomable ? 80 : 'auto',
},
series,

View File

@ -0,0 +1,46 @@
/* eslint-disable no-underscore-dangle */
/**
* 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 { AnnotationOpacity, FormulaAnnotationLayer, TimeseriesDataRecord } from '@superset-ui/core';
import { parse as mathjsParse } from 'mathjs';
export function evalFormula(
formula: FormulaAnnotationLayer,
data: TimeseriesDataRecord[],
): [Date, number][] {
const { value } = formula;
const node = mathjsParse(value);
const func = node.compile();
return data.map(row => {
return [new Date(Number(row.__timestamp)), func.evaluate({ x: row.__timestamp }) as number];
});
}
export function parseAnnotationOpacity(opacity?: AnnotationOpacity): number {
switch (opacity) {
case 'opacityLow':
return 0.2;
case 'opacityMedium':
return 0.5;
case 'opacityHigh':
return 0.8;
default:
return 1;
}
}

View File

@ -17,7 +17,7 @@
* under the License.
*/
import 'babel-polyfill';
import { ChartProps } from '@superset-ui/core';
import { ChartProps, FormulaAnnotationLayer } from '@superset-ui/core';
import transformProps from '../../src/Timeseries/transformProps';
describe('EchartsTimeseries tranformProps', () => {
@ -69,4 +69,62 @@ describe('EchartsTimeseries tranformProps', () => {
}),
);
});
it('should add a formula to viz', () => {
const formula: FormulaAnnotationLayer = {
name: 'My Formula',
annotationType: 'FORMULA',
value: 'x+1',
style: 'solid',
show: true,
};
const formulaChartProps = new ChartProps({
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.objectContaining({
width: 800,
height: 600,
echartOptions: expect.objectContaining({
legend: expect.objectContaining({
data: ['San Francisco', 'New York', 'My Formula'],
}),
series: expect.arrayContaining([
expect.objectContaining({
data: [
[new Date(599616000000), 1],
[new Date(599916000000), 3],
],
name: 'San Francisco',
}),
expect.objectContaining({
data: [
[new Date(599616000000), 2],
[new Date(599916000000), 4],
],
name: 'New York',
}),
expect.objectContaining({
data: [
[new Date(599616000000), 599616000001],
[new Date(599916000000), 599916000001],
],
name: 'My Formula',
}),
]),
}),
}),
);
});
});

View File

@ -0,0 +1,29 @@
/**
* 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 { parseAnnotationOpacity } from '../../src/utils/annotation';
describe('extractForecastSeriesContext', () => {
it('should extract the correct series name and type', () => {
expect(parseAnnotationOpacity('opacityLow')).toEqual(0.2);
expect(parseAnnotationOpacity('opacityMedium')).toEqual(0.5);
expect(parseAnnotationOpacity('opacityHigh')).toEqual(0.8);
expect(parseAnnotationOpacity('')).toEqual(1);
expect(parseAnnotationOpacity(undefined)).toEqual(1);
});
});

View File

@ -4177,6 +4177,13 @@
resolved "https://registry.yarnpkg.com/@types/match-sorter/-/match-sorter-4.0.0.tgz#8a7286019d4e328c09422bb2af2403a94b7038fd"
integrity sha512-JK7HNHXZA7i/nEp6fbNAxoX/1j1ysZXmv2/nlkt2UpX1LiUWKLtyt/dMmDTlMPR6t6PkwMmIr2W2AAyu6oELNw==
"@types/mathjs@^6.0.6":
version "6.0.6"
resolved "https://registry.yarnpkg.com/@types/mathjs/-/mathjs-6.0.6.tgz#47cc33adb7eeec58f657465017594a2cdc84eead"
integrity sha512-FGXJ5r51lmHkoDNNyjNw01gShCR7vMQRTyCLVj7XD8C51bTC+elc7BZBcfcsv5SeQQOVirEY6hbo5ZWvjIF79Q==
dependencies:
decimal.js "^10.0.0"
"@types/micromatch@^4.0.1":
version "4.0.1"
resolved "https://registry.yarnpkg.com/@types/micromatch/-/micromatch-4.0.1.tgz#9381449dd659fc3823fd2a4190ceacc985083bc7"
@ -7238,6 +7245,11 @@ complex.js@2.0.4:
resolved "https://registry.yarnpkg.com/complex.js/-/complex.js-2.0.4.tgz#d8e7cfb9652d1e853e723386421c1a0ca7a48373"
integrity sha512-Syl95HpxUTS0QjwNxencZsKukgh1zdS9uXeXX2Us0pHaqBR6kiZZi0AkZ9VpZFwHJyVIUVzI4EumjWdXP3fy6w==
complex.js@^2.0.11:
version "2.0.11"
resolved "https://registry.yarnpkg.com/complex.js/-/complex.js-2.0.11.tgz#09a873fbf15ffd8c18c9c2201ccef425c32b8bf1"
integrity sha512-6IArJLApNtdg1P1dFtn3dnyzoZBEF0MwMnrfF1exSBRpZYoy4yieMkpZhQDC0uwctw48vii0CFVyHfpgZ/DfGw==
component-emitter@^1.2.1:
version "1.3.0"
resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.3.0.tgz#16e4070fba8ae29b679f2215853ee181ab2eabc0"
@ -8256,6 +8268,11 @@ decimal.js@9.0.1:
resolved "https://registry.yarnpkg.com/decimal.js/-/decimal.js-9.0.1.tgz#1cc8b228177da7ab6498c1cc06eb130a290e6e1e"
integrity sha512-2h0iKbJwnImBk4TGk7CG1xadoA0g3LDPlQhQzbZ221zvG0p2YVUedbKIPsOZXKZGx6YmZMJKYOalpCMxSdDqTQ==
decimal.js@^10.0.0, decimal.js@^10.2.1:
version "10.2.1"
resolved "https://registry.yarnpkg.com/decimal.js/-/decimal.js-10.2.1.tgz#238ae7b0f0c793d3e3cea410108b35a2c01426a3"
integrity sha512-KaL7+6Fw6i5A2XSnsbhm/6B+NuEA7TZ4vqxnd5tXz9sbKtrN9Srj8ab4vKVdK8YAqZO9P1kg45Y6YLoduPf+kw==
decimal.js@^10.2.0:
version "10.2.0"
resolved "https://registry.yarnpkg.com/decimal.js/-/decimal.js-10.2.0.tgz#39466113a9e036111d02f82489b5fd6b0b5ed231"
@ -9094,7 +9111,7 @@ escape-html@^1.0.3, escape-html@~1.0.3:
resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988"
integrity sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg=
escape-latex@^1.0.0:
escape-latex@^1.0.0, escape-latex@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/escape-latex/-/escape-latex-1.2.0.tgz#07c03818cf7dac250cce517f4fda1b001ef2bca1"
integrity sha512-nV5aVWW1K0wEiUIEdZ4erkGGH8mDxGyxSeqPzRNtWP7ataw+/olFObw7hujFWlVjNsaDFw5VZ5NzVSIqRgfTiw==
@ -10117,6 +10134,11 @@ fraction.js@4.0.4:
resolved "https://registry.yarnpkg.com/fraction.js/-/fraction.js-4.0.4.tgz#04e567110718adf7b52974a10434ab4c67a5183e"
integrity sha512-aK/oGatyYLTtXRHjfEsytX5fieeR5H4s8sLorzcT12taFS+dbMZejnvm9gRa8mZAPwci24ucjq9epDyaq5u8Iw==
fraction.js@^4.0.12:
version "4.0.12"
resolved "https://registry.yarnpkg.com/fraction.js/-/fraction.js-4.0.12.tgz#0526d47c65a5fb4854df78bc77f7bec708d7b8c3"
integrity sha512-8Z1K0VTG4hzYY7kA/1sj4/r1/RWLBD3xwReT/RCrUCbzPszjNQCCsy3ktkU/eaEqX3MYa4pY37a52eiBlPMlhA==
fragment-cache@^0.2.1:
version "0.2.1"
resolved "https://registry.yarnpkg.com/fragment-cache/-/fragment-cache-0.2.1.tgz#4290fad27f13e89be7f33799c6bc5a0abfff0d19"
@ -11980,7 +12002,7 @@ java-properties@^1.0.0:
resolved "https://registry.yarnpkg.com/java-properties/-/java-properties-1.0.2.tgz#ccd1fa73907438a5b5c38982269d0e771fe78211"
integrity sha512-qjdpeo2yKlYTH7nFdK0vbZWuTCesk4o63v5iVOlhMQPfuIZQfW/HI35SjfhA+4qpg36rnFSvUK5b1m+ckIblQQ==
javascript-natural-sort@0.7.1:
javascript-natural-sort@0.7.1, javascript-natural-sort@^0.7.1:
version "0.7.1"
resolved "https://registry.yarnpkg.com/javascript-natural-sort/-/javascript-natural-sort-0.7.1.tgz#f9e2303d4507f6d74355a73664d1440fb5a0ef59"
integrity sha1-+eIwPUUH9tdDVac2ZNFED7Wg71k=
@ -13624,6 +13646,20 @@ mathjs@^3.20.2:
tiny-emitter "2.0.2"
typed-function "0.10.7"
mathjs@^7.5.1:
version "7.5.1"
resolved "https://registry.yarnpkg.com/mathjs/-/mathjs-7.5.1.tgz#eb125295310a99ddcaf6145c47b09aab36e48274"
integrity sha512-H2q/Dq0qxBLMw+G84SSXmGqo/znihuxviGgAQwAcyeFLwK2HksvSGNx4f3dllZF51bWOnu2op60VZxH2Sb51Pw==
dependencies:
complex.js "^2.0.11"
decimal.js "^10.2.1"
escape-latex "^1.2.0"
fraction.js "^4.0.12"
javascript-natural-sort "^0.7.1"
seed-random "^2.2.0"
tiny-emitter "^2.1.0"
typed-function "^2.0.0"
md5.js@^1.3.4:
version "1.3.5"
resolved "https://registry.yarnpkg.com/md5.js/-/md5.js-1.3.5.tgz#b5d07b8e3216e3e27cd728d72f70d1e6a342005f"
@ -17001,7 +17037,7 @@ scoped-regex@^1.0.0:
resolved "https://registry.yarnpkg.com/scoped-regex/-/scoped-regex-1.0.0.tgz#a346bb1acd4207ae70bd7c0c7ca9e566b6baddb8"
integrity sha1-o0a7Gs1CB65wvXwMfKnlZra63bg=
seed-random@2.2.0:
seed-random@2.2.0, seed-random@^2.2.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/seed-random/-/seed-random-2.2.0.tgz#2a9b19e250a817099231a5b99a4daf80b7fbed54"
integrity sha1-KpsZ4lCoFwmSMaW5mk2vgLf77VQ=
@ -18303,7 +18339,7 @@ tiny-emitter@2.0.2:
resolved "https://registry.yarnpkg.com/tiny-emitter/-/tiny-emitter-2.0.2.tgz#82d27468aca5ade8e5fd1e6d22b57dd43ebdfb7c"
integrity sha512-2NM0auVBGft5tee/OxP4PI3d8WItkDM+fPnaRAVo6xTDI2knbz9eC5ArWGqtGlYqiH3RU5yMpdyTTO7MguC4ow==
tiny-emitter@^2.0.0:
tiny-emitter@^2.0.0, tiny-emitter@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/tiny-emitter/-/tiny-emitter-2.1.0.tgz#1d1a56edfc51c43e863cbb5382a72330e3555423"
integrity sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==
@ -18578,6 +18614,11 @@ typed-function@0.10.7:
resolved "https://registry.yarnpkg.com/typed-function/-/typed-function-0.10.7.tgz#f702af7d77a64b61abf86799ff2d74266ebc4477"
integrity sha512-3mlZ5AwRMbLvUKkc8a1TI4RUJUS2H27pmD5q0lHRObgsoWzhDAX01yg82kwSP1FUw922/4Y9ZliIEh0qJZcz+g==
typed-function@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/typed-function/-/typed-function-2.0.0.tgz#15ab3825845138a8b1113bd89e60cd6a435739e8"
integrity sha512-Hhy1Iwo/e4AtLZNK10ewVVcP2UEs408DS35ubP825w/YgSBK1KVLwALvvIG4yX75QJrxjCpcWkzkVRB0BwwYlA==
typed-styles@^0.0.7:
version "0.0.7"
resolved "https://registry.yarnpkg.com/typed-styles/-/typed-styles-0.0.7.tgz#93392a008794c4595119ff62dde6809dbc40a3d9"