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:
parent
302c9603c4
commit
2718909314
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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' }),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"""
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
)
|
||||
|
|
|
|||
Loading…
Reference in New Issue