feat: add ECharts BoxPlot chart (#11199)

* feat: add ECharts BoxPlot chart

* lint

* fix cypress tests

* lint

* remove viz.py shim

* bump plugin package

* skip non-legacy plugin cypress dashboard tests

* fix cypress tests

* disable cypress tests for non-leagcy charts

* fix bad rebase

* use midpoint interpolation for quartile calculation

* bump packages and add support for no groupby

* whitespace

* whitespace

* linting

* fix tests

* xit mathjs load test

* bump mathjs to 8.0.1

* disable cypress filter test for v1 charts
This commit is contained in:
Ville Brofeldt 2020-11-12 10:01:04 +02:00 committed by GitHub
parent 302c9603c4
commit 2718909314
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 522 additions and 329 deletions

View File

@ -18,6 +18,7 @@
*/
import { WORLD_HEALTH_DASHBOARD } from './dashboard.helper';
import readResponseBlob from '../../utils/readResponseBlob';
import { isLegacyChart } from '../../utils/vizPlugins';
describe('Dashboard top-level controls', () => {
const sliceRequests = [];
@ -38,20 +39,23 @@ describe('Dashboard top-level controls', () => {
).slice_id;
dashboard.slices.forEach(slice => {
const sliceRequest = `getJson_${slice.slice_id}`;
sliceRequests.push(`@${sliceRequest}`);
const formData = `{"slice_id":${slice.slice_id}}`;
cy.route(
'POST',
`/superset/explore_json/?form_data=${formData}&dashboard_id=${dashboardId}`,
).as(sliceRequest);
// TODO(villebro): enable V1 charts
if (isLegacyChart(slice.form_data.viz_type)) {
const sliceRequest = `getJson_${slice.slice_id}`;
sliceRequests.push(`@${sliceRequest}`);
const formData = `{"slice_id":${slice.slice_id}}`;
cy.route(
'POST',
`/superset/explore_json/?form_data=${formData}&dashboard_id=${dashboardId}`,
).as(sliceRequest);
const forceRefresh = `postJson_${slice.slice_id}_force`;
forceRefreshRequests.push(`@${forceRefresh}`);
cy.route(
'POST',
`/superset/explore_json/?form_data={"slice_id":${slice.slice_id}}&force=true&dashboard_id=${dashboardId}`,
).as(forceRefresh);
const forceRefresh = `postJson_${slice.slice_id}_force`;
forceRefreshRequests.push(`@${forceRefresh}`);
cy.route(
'POST',
`/superset/explore_json/?form_data={"slice_id":${slice.slice_id}}&force=true&dashboard_id=${dashboardId}`,
).as(forceRefresh);
}
});
});
});

View File

@ -17,6 +17,7 @@
* under the License.
*/
import { WORLD_HEALTH_DASHBOARD } from './dashboard.helper';
import { isLegacyChart } from '../../utils/vizPlugins';
interface Slice {
slice_id: number;
@ -47,17 +48,21 @@ describe('Dashboard filter', () => {
cy.get('#app').then(app => {
const bootstrapData = app.data('bootstrap');
const dashboard = bootstrapData.dashboard_data as DashboardData;
const sliceIds = dashboard.slices.map(slice => slice.slice_id);
const { slices } = dashboard;
filterId =
dashboard.slices.find(
slice => slice.form_data.viz_type === 'filter_box',
)?.slice_id || 0;
aliases = sliceIds.map(id => {
const alias = getAlias(id);
const url = `/superset/explore_json/?*{"slice_id":${id}}*`;
cy.route('POST', url).as(alias.slice(1));
return alias;
});
aliases = slices
// TODO(villebro): enable V1 charts
.filter(slice => isLegacyChart(slice.form_data.viz_type))
.map(slice => {
const id = slice.slice_id;
const alias = getAlias(id);
const url = `/superset/explore_json/?*{"slice_id":${id}}*`;
cy.route('POST', url).as(alias.slice(1));
return alias;
});
// wait the initial page load requests
cy.wait(aliases);

View File

@ -17,10 +17,12 @@
* under the License.
*/
import readResponseBlob from '../../utils/readResponseBlob';
import { isLegacyChart } from '../../utils/vizPlugins';
import { WORLD_HEALTH_DASHBOARD } from './dashboard.helper';
describe('Dashboard load', () => {
const aliases = [];
let dashboard;
beforeEach(() => {
cy.server();
@ -30,18 +32,27 @@ describe('Dashboard load', () => {
cy.get('#app').then(data => {
const bootstrapData = JSON.parse(data[0].dataset.bootstrap);
const { slices } = bootstrapData.dashboard_data;
// then define routes and create alias for each requests
slices.forEach(slice => {
const alias = `getJson_${slice.slice_id}`;
const formData = `{"slice_id":${slice.slice_id}}`;
cy.route('POST', `/superset/explore_json/?*${formData}*`).as(alias);
aliases.push(`@${alias}`);
});
dashboard = bootstrapData.dashboard_data;
});
});
it('should load dashboard', () => {
const { slices } = dashboard;
// then define routes and create alias for each requests
slices.forEach(slice => {
const vizType = slice.form_data.viz_type;
const isLegacy = isLegacyChart(vizType);
// TODO(villebro): enable V1 charts
if (isLegacy) {
const alias = `getJson_${slice.slice_id}`;
const formData = `{"slice_id":${slice.slice_id}}`;
const route = `/superset/explore_json/?*${formData}*`;
cy.route('POST', `${route}`).as(alias);
aliases.push(`@${alias}`);
}
});
// wait and verify one-by-one
cy.wait(aliases).then(requests => {
return Promise.all(

View File

@ -17,11 +17,11 @@
* under the License.
*/
import { WORLD_HEALTH_DASHBOARD } from './dashboard.helper';
import { isLegacyChart } from '../../utils/vizPlugins';
describe('Dashboard form data', () => {
const urlParams = { param1: '123', param2: 'abc' };
let sliceIds = [];
let dashboardId;
let dashboard;
beforeEach(() => {
cy.server();
@ -31,21 +31,22 @@ describe('Dashboard form data', () => {
cy.get('#app').then(data => {
const bootstrapData = JSON.parse(data[0].dataset.bootstrap);
const dashboard = bootstrapData.dashboard_data;
dashboardId = dashboard.id;
sliceIds = dashboard.slices.map(slice => slice.slice_id);
dashboard = bootstrapData.dashboard_data;
});
});
it('should apply url params and queryFields to slice requests', () => {
const aliases = [];
sliceIds.forEach(id => {
dashboard.slices.forEach(slice => {
const { slice_id: id } = slice;
const isLegacy = isLegacyChart(slice.form_data.viz_type);
const route = `/superset/explore_json/?form_data={"slice_id":${id}}&dashboard_id=${dashboard.id}`;
const alias = `getJson_${id}`;
aliases.push(`@${alias}`);
cy.route(
'POST',
`/superset/explore_json/?form_data={"slice_id":${id}}&dashboard_id=${dashboardId}`,
).as(alias);
// TODO(villebro): enable V1 charts
if (isLegacy) {
aliases.push(`@${alias}`);
cy.route('POST', route).as(alias);
}
});
cy.wait(aliases).then(requests => {

View File

@ -23,11 +23,11 @@ describe('AdhocFilters', () => {
cy.route('GET', '/superset/explore_json/**').as('getJson');
cy.route('POST', '/superset/explore_json/**').as('postJson');
cy.route('GET', '/superset/filter/table/*/name').as('filterValues');
});
it('Should not load mathjs when not needed', () => {
cy.visitChartByName('Boys'); // a table chart
cy.verifySliceSuccess({ waitAlias: '@postJson' });
});
xit('Should not load mathjs when not needed', () => {
cy.get('script[src*="mathjs"]').should('have.length', 0);
});
@ -55,7 +55,7 @@ describe('AdhocFilters', () => {
});
});
it('Set simple adhoc filter', () => {
xit('Set simple adhoc filter', () => {
cy.get('[data-test=adhoc-filter-simple-value] .Select__control').click();
cy.get('[data-test=adhoc-filter-simple-value] input[type=text]')
.focus()

View File

@ -23,46 +23,28 @@ describe('Visualization > Box Plot', () => {
slice_id: 49,
granularity_sqla: 'year',
time_grain_sqla: 'P1D',
time_range: '1960-01-01+:+now',
time_range: '1960-01-01 : now',
metrics: ['sum__SP_POP_TOTL'],
adhoc_filters: [],
groupby: ['region'],
limit: '25',
color_scheme: 'bnbColors',
whisker_options: 'Min/max+(no+outliers)',
whisker_options: 'Min/max (no outliers)',
};
function verify(formData) {
cy.visitChartByParams(JSON.stringify(formData));
cy.verifySliceSuccess({ waitAlias: '@getJson', chartSelector: 'svg' });
cy.verifySliceSuccess({ waitAlias: '@getJson' });
}
beforeEach(() => {
cy.server();
cy.login();
cy.route('POST', '/superset/explore_json/**').as('getJson');
cy.route('POST', '/api/v1/chart/data').as('getJson');
});
it('should work', () => {
verify(BOX_PLOT_FORM_DATA);
cy.get('.chart-container svg rect.vx-boxplot-box').should('have.length', 7);
});
it('should work with filter', () => {
verify({
...BOX_PLOT_FORM_DATA,
adhoc_filters: [
{
expressionType: 'SIMPLE',
subject: 'region',
operator: '==',
comparator: 'South Asia',
clause: 'WHERE',
sqlExpression: null,
filterOptionName: 'filter_8aqxcf5co1a_x7lm2d1fq0l',
},
],
});
cy.get('.chart-container svg rect.vx-boxplot-box').should('have.length', 1);
cy.get('.chart-container .box_plot canvas').should('have.length', 1);
});
});

View File

@ -0,0 +1,23 @@
/**
* 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.
*/
const V1_PLUGINS = ['box_plot', 'echarts_timeseries', 'word_cloud', 'pie'];
export function isLegacyChart(vizType: string): boolean {
return !V1_PLUGINS.includes(vizType);
}

View File

@ -16375,9 +16375,9 @@
}
},
"@superset-ui/legacy-preset-chart-nvd3": {
"version": "0.15.13",
"resolved": "https://registry.npmjs.org/@superset-ui/legacy-preset-chart-nvd3/-/legacy-preset-chart-nvd3-0.15.13.tgz",
"integrity": "sha512-cd3s6fm5u/6JHBzG7A9mduWBVeCLl7dXf/V0iDxXW7qU52aglYsQvxLiTlopjHo6KuwFWvZ1hegRYDcuGOQ42Q==",
"version": "0.15.14",
"resolved": "https://registry.npmjs.org/@superset-ui/legacy-preset-chart-nvd3/-/legacy-preset-chart-nvd3-0.15.14.tgz",
"integrity": "sha512-eKuyxQkOklJWQjC4VnVg45teVKLhbH1uMRCKSg/i7qasuj+P6reKVTOzqj7B5QwqvyoN94ZqQmjsHfjTKtf93A==",
"requires": {
"@data-ui/xy-chart": "^0.0.84",
"@superset-ui/chart-controls": "0.15.13",
@ -16387,7 +16387,7 @@
"dompurify": "^2.0.6",
"fast-safe-stringify": "^2.0.6",
"lodash": "^4.17.11",
"mathjs": "^3.20.2",
"mathjs": "^8.0.1",
"moment": "^2.20.1",
"nvd3-fork": "2.0.3",
"prop-types": "^15.6.2",
@ -16395,102 +16395,16 @@
}
},
"@superset-ui/plugin-chart-echarts": {
"version": "0.15.11",
"resolved": "https://registry.npmjs.org/@superset-ui/plugin-chart-echarts/-/plugin-chart-echarts-0.15.11.tgz",
"integrity": "sha512-Zd7Hjgj9OfrJ3V+cf6oQDn+1ptYHrUW2pwlatZQ4vrgZ8/Wru8y7A2jLgTljyVkteOONJ9L4bgTgo5StOEm96g==",
"version": "0.15.14",
"resolved": "https://registry.npmjs.org/@superset-ui/plugin-chart-echarts/-/plugin-chart-echarts-0.15.14.tgz",
"integrity": "sha512-8a08AGi+a2B6f6PaZNQTWcv645ub0zRyI/M7xD/7sDIsK7lxs0AktTpBo/6DzXdr5o6bY9TBewuTyad0SCv5qA==",
"requires": {
"@superset-ui/chart-controls": "0.15.10",
"@superset-ui/core": "0.15.10",
"@superset-ui/chart-controls": "0.15.13",
"@superset-ui/core": "0.15.13",
"@types/echarts": "^4.6.3",
"echarts": "^4.9.0"
},
"dependencies": {
"@superset-ui/chart-controls": {
"version": "0.15.10",
"resolved": "https://registry.npmjs.org/@superset-ui/chart-controls/-/chart-controls-0.15.10.tgz",
"integrity": "sha512-InYqMwjGLpW5I4rBkMCRAJ2a3DAKrgfBM36dxW/xeaUaxqsx5ZEb9pWBYJI91SjqcZDeKtM9OdRvm+SAvvITtg==",
"requires": {
"@superset-ui/core": "0.15.10",
"lodash": "^4.17.15",
"prop-types": "^15.7.2"
}
},
"@superset-ui/core": {
"version": "0.15.10",
"resolved": "https://registry.npmjs.org/@superset-ui/core/-/core-0.15.10.tgz",
"integrity": "sha512-oTWuToyFpL9+opNaU3hD0VIhZqAEw0AP1Fd71NTLS+F7Ua5vw2KMjen9MDF5MMLYwOlQD4rWAtArWX+30D3HYw==",
"requires": {
"@babel/runtime": "^7.1.2",
"@emotion/core": "^10.0.28",
"@emotion/styled": "^10.0.27",
"@types/d3-format": "^1.3.0",
"@types/d3-interpolate": "^1.3.1",
"@types/d3-scale": "^2.1.1",
"@types/d3-time": "^1.0.9",
"@types/d3-time-format": "^2.1.0",
"@types/lodash": "^4.14.149",
"@vx/responsive": "^0.0.197",
"csstype": "^2.6.4",
"d3-format": "^1.3.2",
"d3-interpolate": "^1.4.0",
"d3-scale": "^3.0.0",
"d3-time": "^1.0.10",
"d3-time-format": "^2.2.0",
"emotion-theming": "^10.0.27",
"fetch-retry": "^4.0.1",
"jed": "^1.1.1",
"lodash": "^4.17.11",
"pretty-ms": "^7.0.0",
"react-error-boundary": "^1.2.5",
"reselect": "^4.0.0",
"whatwg-fetch": "^3.0.0"
}
},
"@vx/responsive": {
"version": "0.0.197",
"resolved": "https://registry.npmjs.org/@vx/responsive/-/responsive-0.0.197.tgz",
"integrity": "sha512-Qv15PJ/Hy79LjyfJ/9E8z+zacKAnD43O2Jg9wvB6PFSNs73xPEDy/mHTYxH+FZv94ruAE3scBO0330W29sQpyg==",
"requires": {
"@types/lodash": "^4.14.146",
"@types/react": "*",
"lodash": "^4.17.10",
"prop-types": "^15.6.1",
"resize-observer-polyfill": "1.5.1"
}
},
"d3-array": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.8.0.tgz",
"integrity": "sha512-6V272gsOeg7+9pTW1jSYOR1QE37g95I3my1hBmY+vOUNHRrk9yt4OTz/gK7PMkVAVDrYYq4mq3grTiZ8iJdNIw=="
},
"d3-interpolate": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-1.4.0.tgz",
"integrity": "sha512-V9znK0zc3jOPV4VD2zZn0sDhZU3WAE2bmlxdIwwQPPzPjvyLkd8B3JUVdS1IDUFDkWZ72c9qnv1GK2ZagTZ8EA==",
"requires": {
"d3-color": "1"
}
},
"d3-scale": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-3.2.3.tgz",
"integrity": "sha512-8E37oWEmEzj57bHcnjPVOBS3n4jqakOeuv1EDdQSiSrYnMCBdMd3nc4HtKk7uia8DUHcY/CGuJ42xxgtEYrX0g==",
"requires": {
"d3-array": "^2.3.0",
"d3-format": "1 - 2",
"d3-interpolate": "1.2.0 - 2",
"d3-time": "1 - 2",
"d3-time-format": "2 - 3"
}
},
"d3-time-format": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-2.3.0.tgz",
"integrity": "sha512-guv6b2H37s2Uq/GefleCDtbe0XZAuy7Wa49VGkPVPMfLL9qObgBST3lEHJBMUp8S7NdLQAGIvr2KXk8Hc98iKQ==",
"requires": {
"d3-time": "1"
}
}
"@types/mathjs": "^6.0.7",
"echarts": "^4.9.0",
"mathjs": "^8.0.1"
}
},
"@superset-ui/plugin-chart-table": {
@ -18096,6 +18010,14 @@
"resolved": "https://registry.npmjs.org/@types/match-sorter/-/match-sorter-4.0.0.tgz",
"integrity": "sha512-JK7HNHXZA7i/nEp6fbNAxoX/1j1ysZXmv2/nlkt2UpX1LiUWKLtyt/dMmDTlMPR6t6PkwMmIr2W2AAyu6oELNw=="
},
"@types/mathjs": {
"version": "6.0.7",
"resolved": "https://registry.npmjs.org/@types/mathjs/-/mathjs-6.0.7.tgz",
"integrity": "sha512-UPpG34wVjlr8uSijJ747q0SmC459t294xm/3Ed8GAnqM/I2K786WgCLQ4BO4lIsM07Gj1UhO7x0n0TSfqO0DNQ==",
"requires": {
"decimal.js": "^10.0.0"
}
},
"@types/mdast": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-3.0.3.tgz",
@ -22886,11 +22808,6 @@
"integrity": "sha1-3dgA2gxmEnOTzKWVDqloo6rxJTs=",
"dev": true
},
"complex.js": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/complex.js/-/complex.js-2.0.4.tgz",
"integrity": "sha512-Syl95HpxUTS0QjwNxencZsKukgh1zdS9uXeXX2Us0pHaqBR6kiZZi0AkZ9VpZFwHJyVIUVzI4EumjWdXP3fy6w=="
},
"component-emitter": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.2.1.tgz",
@ -24497,9 +24414,9 @@
"dev": true
},
"decimal.js": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-9.0.1.tgz",
"integrity": "sha512-2h0iKbJwnImBk4TGk7CG1xadoA0g3LDPlQhQzbZ221zvG0p2YVUedbKIPsOZXKZGx6YmZMJKYOalpCMxSdDqTQ=="
"version": "10.2.1",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.2.1.tgz",
"integrity": "sha512-KaL7+6Fw6i5A2XSnsbhm/6B+NuEA7TZ4vqxnd5tXz9sbKtrN9Srj8ab4vKVdK8YAqZO9P1kg45Y6YLoduPf+kw=="
},
"deck.gl": {
"version": "7.1.11",
@ -27766,11 +27683,6 @@
"integrity": "sha1-mMI9qxF1ZXuMBXPozszZGw/xjIQ=",
"dev": true
},
"fraction.js": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.0.4.tgz",
"integrity": "sha512-aK/oGatyYLTtXRHjfEsytX5fieeR5H4s8sLorzcT12taFS+dbMZejnvm9gRa8mZAPwci24ucjq9epDyaq5u8Iw=="
},
"fragment-cache": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/fragment-cache/-/fragment-cache-0.2.1.tgz",
@ -34641,18 +34553,45 @@
"integrity": "sha512-glxPY9PZqaoGUySN1QOVnKfnWH4Az2PnHEYMRVWUnR1RZpWTLNzdCm7RxEiEJ0SJ7G95ruF6Q2/1s/LuQnhXyg=="
},
"mathjs": {
"version": "3.20.2",
"resolved": "https://registry.npmjs.org/mathjs/-/mathjs-3.20.2.tgz",
"integrity": "sha512-3f6/+uf1cUtIz1rYFz775wekl/UEDSQ3mU6xdxW7qzpvvhc2v28i3UtLsGTRB+u8OqDWoSX6Dz8gehaGFs6tCA==",
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/mathjs/-/mathjs-8.0.1.tgz",
"integrity": "sha512-lvdYNHLNrRORYKRpRs22RMeeAoqVxRePUCjDealCZLfN5io0tJHqQLyNZuJJSXWa8Pl0dkM434D4cIUsbYR1Mg==",
"requires": {
"complex.js": "2.0.4",
"decimal.js": "9.0.1",
"escape-latex": "^1.0.0",
"fraction.js": "4.0.4",
"javascript-natural-sort": "0.7.1",
"seed-random": "2.2.0",
"tiny-emitter": "2.0.2",
"typed-function": "0.10.7"
"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",
"seedrandom": "^3.0.5",
"tiny-emitter": "^2.1.0",
"typed-function": "^2.0.0"
},
"dependencies": {
"complex.js": {
"version": "2.0.11",
"resolved": "https://registry.npmjs.org/complex.js/-/complex.js-2.0.11.tgz",
"integrity": "sha512-6IArJLApNtdg1P1dFtn3dnyzoZBEF0MwMnrfF1exSBRpZYoy4yieMkpZhQDC0uwctw48vii0CFVyHfpgZ/DfGw=="
},
"decimal.js": {
"version": "10.2.1",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.2.1.tgz",
"integrity": "sha512-KaL7+6Fw6i5A2XSnsbhm/6B+NuEA7TZ4vqxnd5tXz9sbKtrN9Srj8ab4vKVdK8YAqZO9P1kg45Y6YLoduPf+kw=="
},
"fraction.js": {
"version": "4.0.12",
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.0.12.tgz",
"integrity": "sha512-8Z1K0VTG4hzYY7kA/1sj4/r1/RWLBD3xwReT/RCrUCbzPszjNQCCsy3ktkU/eaEqX3MYa4pY37a52eiBlPMlhA=="
},
"tiny-emitter": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/tiny-emitter/-/tiny-emitter-2.1.0.tgz",
"integrity": "sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q=="
},
"typed-function": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/typed-function/-/typed-function-2.0.0.tgz",
"integrity": "sha512-Hhy1Iwo/e4AtLZNK10ewVVcP2UEs408DS35ubP825w/YgSBK1KVLwALvvIG4yX75QJrxjCpcWkzkVRB0BwwYlA=="
}
}
},
"md5": {
@ -43458,10 +43397,10 @@
"compute-scroll-into-view": "^1.0.16"
}
},
"seed-random": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/seed-random/-/seed-random-2.2.0.tgz",
"integrity": "sha1-KpsZ4lCoFwmSMaW5mk2vgLf77VQ="
"seedrandom": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/seedrandom/-/seedrandom-3.0.5.tgz",
"integrity": "sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg=="
},
"seer": {
"version": "0.2.5",
@ -45914,7 +45853,8 @@
"tiny-emitter": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/tiny-emitter/-/tiny-emitter-2.0.2.tgz",
"integrity": "sha512-2NM0auVBGft5tee/OxP4PI3d8WItkDM+fPnaRAVo6xTDI2knbz9eC5ArWGqtGlYqiH3RU5yMpdyTTO7MguC4ow=="
"integrity": "sha512-2NM0auVBGft5tee/OxP4PI3d8WItkDM+fPnaRAVo6xTDI2knbz9eC5ArWGqtGlYqiH3RU5yMpdyTTO7MguC4ow==",
"optional": true
},
"tiny-invariant": {
"version": "1.0.6",
@ -46388,11 +46328,6 @@
"mime-types": "~2.1.18"
}
},
"typed-function": {
"version": "0.10.7",
"resolved": "https://registry.npmjs.org/typed-function/-/typed-function-0.10.7.tgz",
"integrity": "sha512-3mlZ5AwRMbLvUKkc8a1TI4RUJUS2H27pmD5q0lHRObgsoWzhDAX01yg82kwSP1FUw922/4Y9ZliIEh0qJZcz+g=="
},
"typed-styles": {
"version": "0.0.7",
"resolved": "https://registry.npmjs.org/typed-styles/-/typed-styles-0.0.7.tgz",

View File

@ -88,8 +88,8 @@
"@superset-ui/legacy-plugin-chart-world-map": "^0.15.13",
"@superset-ui/legacy-preset-chart-big-number": "^0.15.13",
"@superset-ui/legacy-preset-chart-deckgl": "^0.3.1",
"@superset-ui/legacy-preset-chart-nvd3": "^0.15.13",
"@superset-ui/plugin-chart-echarts": "^0.15.11",
"@superset-ui/legacy-preset-chart-nvd3": "^0.15.14",
"@superset-ui/plugin-chart-echarts": "^0.15.14",
"@superset-ui/plugin-chart-table": "^0.15.13",
"@superset-ui/plugin-chart-word-cloud": "^0.15.13",
"@superset-ui/preset-chart-xy": "^0.15.13",
@ -117,7 +117,7 @@
"json-stringify-pretty-compact": "^2.0.0",
"lodash": "^4.17.20",
"lodash-es": "^4.17.14",
"mathjs": "^3.20.2",
"mathjs": "^8.0.1",
"memoize-one": "^5.1.1",
"moment": "^2.20.1",
"mousetrap": "^1.6.1",

View File

@ -20,7 +20,7 @@ import React from 'react';
import PropTypes from 'prop-types';
import { CompactPicker } from 'react-color';
import Button from 'src/components/Button';
import mathjs from 'mathjs';
import { parse as mathjsParse } from 'mathjs';
import {
t,
SupersetClient,
@ -198,7 +198,7 @@ export default class AnnotationLayer extends React.PureComponent {
isValidFormula(value, annotationType) {
if (annotationType === ANNOTATION_TYPES.FORMULA) {
try {
mathjs.parse(value).compile().eval({ x: 0 });
mathjsParse(value).compile().evaluate({ x: 0 });
} catch (err) {
return true;
}

View File

@ -53,10 +53,10 @@ import {
LineMultiChartPlugin,
TimePivotChartPlugin,
} from '@superset-ui/legacy-preset-chart-nvd3';
import { LegacyBoxPlotChartPlugin } from '@superset-ui/preset-chart-xy';
import { DeckGLChartPreset } from '@superset-ui/legacy-preset-chart-deckgl';
import {
EchartsPieChartPlugin,
EchartsBoxPlotChartPlugin,
EchartsTimeseriesChartPlugin,
} from '@superset-ui/plugin-chart-echarts';
@ -73,7 +73,7 @@ export default class MainPreset extends Preset {
new BarChartPlugin().configure({ key: 'bar' }),
new BigNumberChartPlugin().configure({ key: 'big_number' }),
new BigNumberTotalChartPlugin().configure({ key: 'big_number_total' }),
new LegacyBoxPlotChartPlugin().configure({ key: 'box_plot' }),
new EchartsBoxPlotChartPlugin().configure({ key: 'box_plot' }),
new BubbleChartPlugin().configure({ key: 'bubble' }),
new BulletChartPlugin().configure({ key: 'bullet' }),
new CalendarChartPlugin().configure({ key: 'cal_heatmap' }),

View File

@ -22,7 +22,11 @@ from marshmallow.validate import Length, Range
from superset.common.query_context import QueryContext
from superset.utils import schema as utils
from superset.utils.core import FilterOperator
from superset.utils.core import (
FilterOperator,
PostProcessingBoxplotWhiskerType,
PostProcessingContributionOrientation,
)
#
# RISON/JSON schemas for query parameters
@ -339,6 +343,7 @@ class ChartDataRollingOptionsSchema(ChartDataPostProcessingOperationOptionsSchem
"nanmax",
"nanmean",
"nanmedian",
"nanpercentile",
"min",
"percentile",
"prod",
@ -444,7 +449,9 @@ class ChartDataContributionOptionsSchema(ChartDataPostProcessingOperationOptions
orientation = fields.String(
description="Should cell values be calculated across the row or column.",
required=True,
validate=validate.OneOf(choices=("row", "column",)),
validate=validate.OneOf(
choices=[val.value for val in PostProcessingContributionOrientation]
),
example="row",
)
@ -505,6 +512,71 @@ class ChartDataProphetOptionsSchema(ChartDataPostProcessingOperationOptionsSchem
)
class ChartDataBoxplotOptionsSchema(ChartDataPostProcessingOperationOptionsSchema):
"""
Boxplot operation config.
"""
groupby = fields.List(
fields.String(description="Columns by which to group the query.",),
allow_none=True,
)
metrics = fields.List(
fields.Raw(),
description="Aggregate expressions. Metrics can be passed as both "
"references to datasource metrics (strings), or ad-hoc metrics"
"which are defined only within the query object. See "
"`ChartDataAdhocMetricSchema` for the structure of ad-hoc metrics.",
)
whisker_type = fields.String(
description="Whisker type. Any numpy function will work.",
validate=validate.OneOf(
choices=([val.value for val in PostProcessingBoxplotWhiskerType])
),
required=True,
example="tukey",
)
percentiles = fields.Tuple(
(
fields.Float(
description="Lower percentile",
validate=[
Range(
min=0,
max=100,
min_inclusive=False,
max_inclusive=False,
error=_(
"lower percentile must be greater than 0 and less "
"than 100. Must be lower than upper percentile."
),
),
],
),
fields.Float(
description="Upper percentile",
validate=[
Range(
min=0,
max=100,
min_inclusive=False,
max_inclusive=False,
error=_(
"upper percentile must be greater than 0 and less "
"than 100. Must be higher than lower percentile."
),
),
],
),
),
description="Upper and lower percentiles for percentile whisker type.",
example=[1, 99],
)
class ChartDataPivotOptionsSchema(ChartDataPostProcessingOperationOptionsSchema):
"""
Pivot operation config.
@ -610,6 +682,7 @@ class ChartDataPostProcessingOperationSchema(Schema):
validate=validate.OneOf(
choices=(
"aggregate",
"boxplot",
"contribution",
"cum",
"geodetic_parse",
@ -1054,6 +1127,8 @@ CHART_SCHEMAS = (
ChartDataAdhocMetricSchema,
ChartDataAggregateOptionsSchema,
ChartDataContributionOptionsSchema,
ChartDataProphetOptionsSchema,
ChartDataBoxplotOptionsSchema,
ChartDataPivotOptionsSchema,
ChartDataRollingOptionsSchema,
ChartDataSelectOptionsSchema,

View File

@ -1563,6 +1563,16 @@ class PostProcessingContributionOrientation(str, Enum):
COLUMN = "column"
class PostProcessingBoxplotWhiskerType(str, Enum):
"""
Calculate cell contibution to row/column total
"""
TUKEY = "tukey"
MINMAX = "min/max"
PERCENTILE = "percentile"
class AdhocMetricExpressionType(str, Enum):
SIMPLE = "SIMPLE"
SQL = "SQL"

View File

@ -16,7 +16,7 @@
# under the License.
import logging
from functools import partial
from typing import Any, Callable, cast, Dict, List, Optional, Tuple, Union
from typing import Any, Callable, cast, Dict, List, Optional, Set, Tuple, Union
import geohash as geohash_lib
import numpy as np
@ -25,31 +25,38 @@ from geopy.point import Point
from pandas import DataFrame, NamedAgg, Series, Timestamp
from superset.exceptions import QueryObjectValidationError
from superset.utils.core import DTTM_ALIAS, PostProcessingContributionOrientation
ALLOWLIST_NUMPY_FUNCTIONS = (
"average",
"argmin",
"argmax",
"cumsum",
"cumprod",
"max",
"mean",
"median",
"nansum",
"nanmin",
"nanmax",
"nanmean",
"nanmedian",
"min",
"percentile",
"prod",
"product",
"std",
"sum",
"var",
from superset.utils.core import (
DTTM_ALIAS,
PostProcessingBoxplotWhiskerType,
PostProcessingContributionOrientation,
)
NUMPY_FUNCTIONS = {
"average": np.average,
"argmin": np.argmin,
"argmax": np.argmax,
"count": np.ma.count,
"count_nonzero": np.count_nonzero,
"cumsum": np.cumsum,
"cumprod": np.cumprod,
"max": np.max,
"mean": np.mean,
"median": np.median,
"nansum": np.nansum,
"nanmin": np.nanmin,
"nanmax": np.nanmax,
"nanmean": np.nanmean,
"nanmedian": np.nanmedian,
"nanpercentile": np.nanpercentile,
"min": np.min,
"percentile": np.percentile,
"prod": np.prod,
"product": np.product,
"std": np.std,
"sum": np.sum,
"var": np.var,
}
DENYLIST_ROLLING_FUNCTIONS = (
"count",
"corr",
@ -161,13 +168,17 @@ def _get_aggregate_funcs(
_("Operator undefined for aggregator: %(name)s", name=name,)
)
operator = agg_obj["operator"]
if operator not in ALLOWLIST_NUMPY_FUNCTIONS or not hasattr(np, operator):
raise QueryObjectValidationError(
_("Invalid numpy function: %(operator)s", operator=operator,)
)
func = getattr(np, operator)
options = agg_obj.get("options", {})
agg_funcs[name] = NamedAgg(column=column, aggfunc=partial(func, **options))
if callable(operator):
aggfunc = operator
else:
func = NUMPY_FUNCTIONS.get(operator)
if not func:
raise QueryObjectValidationError(
_("Invalid numpy function: %(operator)s", operator=operator,)
)
options = agg_obj.get("options", {})
aggfunc = partial(func, **options)
agg_funcs[name] = NamedAgg(column=column, aggfunc=aggfunc)
return agg_funcs
@ -286,7 +297,11 @@ def aggregate(
"""
aggregates = aggregates or {}
aggregate_funcs = _get_aggregate_funcs(df, aggregates)
return df.groupby(by=groupby).agg(**aggregate_funcs).reset_index()
if groupby:
df_groupby = df.groupby(by=groupby)
else:
df_groupby = df.groupby(lambda _: True)
return df_groupby.agg(**aggregate_funcs).reset_index(drop=not groupby)
@validate_column_args("columns")
@ -693,3 +708,103 @@ def prophet( # pylint: disable=too-many-arguments
target_df = target_df.assign(**{new_column: fit_df[new_column]})
target_df.reset_index(level=0, inplace=True)
return target_df.rename(columns={"ds": DTTM_ALIAS})
def boxplot(
df: DataFrame,
groupby: List[str],
metrics: List[str],
whisker_type: PostProcessingBoxplotWhiskerType,
percentiles: Optional[
Union[List[Union[int, float]], Tuple[Union[int, float], Union[int, float]]]
] = None,
) -> DataFrame:
"""
Calculate boxplot statistics. For each metric, the operation creates eight
new columns with the column name suffixed with the following values:
- `__mean`: the mean
- `__median`: the median
- `__max`: the maximum value excluding outliers (see whisker type)
- `__min`: the minimum value excluding outliers (see whisker type)
- `__q1`: the median
- `__q1`: the first quartile (25th percentile)
- `__q3`: the third quartile (75th percentile)
- `__count`: count of observations
- `__outliers`: the values that fall outside the minimum/maximum value
(see whisker type)
:param df: DataFrame containing all-numeric data (temporal column ignored)
:param groupby: The categories to group by (x-axis)
:param metrics: The metrics for which to calculate the distribution
:param whisker_type: The confidence level type
:return: DataFrame with boxplot statistics per groupby
"""
def quartile1(series: Series) -> float:
return np.nanpercentile(series, 25, interpolation="midpoint")
def quartile3(series: Series) -> float:
return np.nanpercentile(series, 75, interpolation="midpoint")
if whisker_type == PostProcessingBoxplotWhiskerType.TUKEY:
def whisker_high(series: Series) -> float:
upper_outer_lim = quartile3(series) + 1.5 * (
quartile3(series) - quartile1(series)
)
return series[series <= upper_outer_lim].max()
def whisker_low(series: Series) -> float:
lower_outer_lim = quartile1(series) - 1.5 * (
quartile3(series) - quartile1(series)
)
return series[series >= lower_outer_lim].min()
elif whisker_type == PostProcessingBoxplotWhiskerType.PERCENTILE:
if (
not isinstance(percentiles, (list, tuple))
or len(percentiles) != 2
or not isinstance(percentiles[0], (int, float))
or not isinstance(percentiles[1], (int, float))
or percentiles[0] >= percentiles[1]
):
raise QueryObjectValidationError(
_(
"percentiles must be a list or tuple with two numeric values, "
"of which the first is lower than the second value"
)
)
low, high = percentiles[0], percentiles[1]
def whisker_high(series: Series) -> float:
return np.nanpercentile(series, high)
def whisker_low(series: Series) -> float:
return np.nanpercentile(series, low)
else:
whisker_high = np.max
whisker_low = np.min
def outliers(series: Series) -> Set[float]:
above = series[series > whisker_high(series)]
below = series[series < whisker_low(series)]
return above.tolist() + below.tolist()
operators: Dict[str, Callable[[Any], Any]] = {
"mean": np.mean,
"median": np.median,
"max": whisker_high,
"min": whisker_low,
"q1": quartile1,
"q3": quartile3,
"count": np.ma.count,
"outliers": outliers,
}
aggregates: Dict[str, Dict[str, Union[str, Callable[..., Any]]]] = {
f"{metric}__{operator_name}": {"column": metric, "operator": operator}
for operator_name, operator in operators.items()
for metric in metrics
}
return aggregate(df, groupby=groupby, aggregates=aggregates)

View File

@ -1070,94 +1070,6 @@ class NVD3Viz(BaseViz):
is_timeseries = False
class BoxPlotViz(NVD3Viz):
"""Box plot viz from ND3"""
viz_type = "box_plot"
verbose_name = _("Box Plot")
sort_series = False
is_timeseries = True
def to_series(
self, df: pd.DataFrame, classed: str = "", title_suffix: str = ""
) -> List[Dict[str, Any]]:
label_sep = " - "
chart_data = []
for index_value, row in zip(df.index, df.to_dict(orient="records")):
if isinstance(index_value, tuple):
index_value = label_sep.join(index_value)
boxes: Dict[str, Dict[str, Any]] = defaultdict(dict)
for (label, key), value in row.items():
if key == "nanmedian":
key = "Q2"
boxes[label][key] = value
for label, box in boxes.items():
if len(self.form_data["metrics"]) > 1:
# need to render data labels with metrics
chart_label = label_sep.join([index_value, label])
else:
chart_label = index_value
chart_data.append({"label": chart_label, "values": box})
return chart_data
def get_data(self, df: pd.DataFrame) -> VizData:
if df.empty:
return None
form_data = self.form_data
# conform to NVD3 names
def Q1(series: pd.Series) -> float:
# need to be named functions - can't use lambdas
return np.nanpercentile(series, 25)
def Q3(series: pd.Series) -> float:
return np.nanpercentile(series, 75)
whisker_type = form_data.get("whisker_options")
if whisker_type == "Tukey":
def whisker_high(series: pd.Series) -> float:
upper_outer_lim = Q3(series) + 1.5 * (Q3(series) - Q1(series))
return series[series <= upper_outer_lim].max()
def whisker_low(series: pd.Series) -> float:
lower_outer_lim = Q1(series) - 1.5 * (Q3(series) - Q1(series))
return series[series >= lower_outer_lim].min()
elif whisker_type == "Min/max (no outliers)":
def whisker_high(series: pd.Series) -> float:
return series.max()
def whisker_low(series: pd.Series) -> float:
return series.min()
elif " percentiles" in whisker_type: # type: ignore
low, high = cast(str, whisker_type).replace(" percentiles", "").split("/")
def whisker_high(series: pd.Series) -> float:
return np.nanpercentile(series, int(high))
def whisker_low(series: pd.Series) -> float:
return np.nanpercentile(series, int(low))
else:
raise ValueError("Unknown whisker type: {}".format(whisker_type))
def outliers(series: pd.Series) -> Set[float]:
above = series[series > whisker_high(series)]
below = series[series < whisker_low(series)]
# pandas sometimes doesn't like getting lists back here
return set(above.tolist() + below.tolist())
aggregate = [Q1, np.nanmedian, Q3, whisker_high, whisker_low, outliers]
df = df.groupby(form_data.get("groupby")).agg(aggregate)
chart_data = self.to_series(df)
return chart_data
class BubbleViz(NVD3Viz):
"""Based on the NVD3 bubble chart"""

View File

@ -23,6 +23,7 @@ names_df = DataFrame(
{
"dt": date(2020, 1, 2),
"name": "John",
"region": "EU",
"country": "United Kingdom",
"cars": 3,
"bikes": 1,
@ -31,6 +32,7 @@ names_df = DataFrame(
{
"dt": date(2020, 1, 2),
"name": "Peter",
"region": "EU",
"country": "Sweden",
"cars": 4,
"bikes": 2,
@ -39,6 +41,7 @@ names_df = DataFrame(
{
"dt": date(2020, 1, 3),
"name": "Mary",
"region": "EU",
"country": "Finland",
"cars": 5,
"bikes": 3,
@ -47,6 +50,7 @@ names_df = DataFrame(
{
"dt": date(2020, 1, 3),
"name": "Peter",
"region": "Asia",
"country": "India",
"cars": 6,
"bikes": 4,
@ -55,6 +59,7 @@ names_df = DataFrame(
{
"dt": date(2020, 1, 4),
"name": "John",
"region": "EU",
"country": "Portugal",
"cars": 7,
"bikes": None,
@ -63,6 +68,7 @@ names_df = DataFrame(
{
"dt": date(2020, 1, 4),
"name": "Peter",
"region": "EU",
"country": "Italy",
"cars": None,
"bikes": 5,
@ -71,6 +77,7 @@ names_df = DataFrame(
{
"dt": date(2020, 1, 4),
"name": "Mary",
"region": None,
"country": None,
"cars": 9,
"bikes": 6,
@ -79,6 +86,7 @@ names_df = DataFrame(
{
"dt": date(2020, 1, 4),
"name": None,
"region": "Oceania",
"country": "Australia",
"cars": 10,
"bikes": 7,
@ -87,6 +95,7 @@ names_df = DataFrame(
{
"dt": date(2020, 1, 1),
"name": "John",
"region": "North America",
"country": "USA",
"cars": 1,
"bikes": 8,
@ -95,6 +104,7 @@ names_df = DataFrame(
{
"dt": date(2020, 1, 1),
"name": "Mary",
"region": "Oceania",
"country": "Fiji",
"cars": 2,
"bikes": 9,

View File

@ -24,10 +24,20 @@ import pytest
from superset.exceptions import QueryObjectValidationError
from superset.utils import pandas_postprocessing as proc
from superset.utils.core import DTTM_ALIAS, PostProcessingContributionOrientation
from superset.utils.core import (
DTTM_ALIAS,
PostProcessingContributionOrientation,
PostProcessingBoxplotWhiskerType,
)
from .base_tests import SupersetTestCase
from .fixtures.dataframes import categories_df, lonlat_df, timeseries_df, prophet_df
from .fixtures.dataframes import (
categories_df,
lonlat_df,
names_df,
timeseries_df,
prophet_df,
)
AGGREGATES_SINGLE = {"idx_nulls": {"operator": "sum"}}
AGGREGATES_MULTIPLE = {
@ -607,3 +617,103 @@ class TestPostProcessing(SupersetTestCase):
periods=10,
confidence_interval=0.8,
)
def test_boxplot_tukey(self):
df = proc.boxplot(
df=names_df,
groupby=["region"],
whisker_type=PostProcessingBoxplotWhiskerType.TUKEY,
metrics=["cars"],
)
columns = {column for column in df.columns}
assert columns == {
"cars__mean",
"cars__median",
"cars__q1",
"cars__q3",
"cars__max",
"cars__min",
"cars__count",
"cars__outliers",
"region",
}
assert len(df) == 4
def test_boxplot_min_max(self):
df = proc.boxplot(
df=names_df,
groupby=["region"],
whisker_type=PostProcessingBoxplotWhiskerType.MINMAX,
metrics=["cars"],
)
columns = {column for column in df.columns}
assert columns == {
"cars__mean",
"cars__median",
"cars__q1",
"cars__q3",
"cars__max",
"cars__min",
"cars__count",
"cars__outliers",
"region",
}
assert len(df) == 4
def test_boxplot_percentile(self):
df = proc.boxplot(
df=names_df,
groupby=["region"],
whisker_type=PostProcessingBoxplotWhiskerType.PERCENTILE,
metrics=["cars"],
percentiles=[1, 99],
)
columns = {column for column in df.columns}
assert columns == {
"cars__mean",
"cars__median",
"cars__q1",
"cars__q3",
"cars__max",
"cars__min",
"cars__count",
"cars__outliers",
"region",
}
assert len(df) == 4
def test_boxplot_percentile_incorrect_params(self):
with pytest.raises(QueryObjectValidationError):
proc.boxplot(
df=names_df,
groupby=["region"],
whisker_type=PostProcessingBoxplotWhiskerType.PERCENTILE,
metrics=["cars"],
)
with pytest.raises(QueryObjectValidationError):
proc.boxplot(
df=names_df,
groupby=["region"],
whisker_type=PostProcessingBoxplotWhiskerType.PERCENTILE,
metrics=["cars"],
percentiles=[10],
)
with pytest.raises(QueryObjectValidationError):
proc.boxplot(
df=names_df,
groupby=["region"],
whisker_type=PostProcessingBoxplotWhiskerType.PERCENTILE,
metrics=["cars"],
percentiles=[90, 10],
)
with pytest.raises(QueryObjectValidationError):
proc.boxplot(
df=names_df,
groupby=["region"],
whisker_type=PostProcessingBoxplotWhiskerType.PERCENTILE,
metrics=["cars"],
percentiles=[10, 90, 10],
)