You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by ma...@apache.org on 2018/05/22 00:47:25 UTC
[incubator-superset] branch master updated: Visualization for
multiple line charts (#4819)
This is an automated email from the ASF dual-hosted git repository.
maximebeauchemin pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/incubator-superset.git
The following commit(s) were added to refs/heads/master by this push:
new 459cb70 Visualization for multiple line charts (#4819)
459cb70 is described below
commit 459cb701fb2140fcce8b97a1839a9511574375c7
Author: Beto Dealmeida <ro...@dealmeida.net>
AuthorDate: Mon May 21 17:47:21 2018 -0700
Visualization for multiple line charts (#4819)
* Initial test
* Save
* Working version
* Use since/until from payload
* Option to prefix metric name
* Rename LineMultiLayer to MultiLineViz
* Add more styles
* Explicit nulls
* Add more x controls
* Refactor to reuse nvd3_vis
* Fix x ticks
* Fix spacing
* Fix for druid datasource
* Rename file
* Small fixes and cleanup
* Fix margins
* Add proper thumbnails
* Align yaxis1 and yaxis2 ticks
* Improve code
* Trigger tests
* Move file
* Small fixes plus example
* Fix unit test
* Remove SQL and Filter sections
---
.../assets/images/viz_thumbnails/line_multi.png | Bin 0 -> 54363 bytes
.../images/viz_thumbnails_large/line_multi.png | Bin 0 -> 116138 bytes
.../explore/components/ExploreViewContainer.jsx | 7 +-
.../components/controls/DatasourceControl.jsx | 2 +-
superset/assets/src/explore/controls.jsx | 43 +++++++++
superset/assets/src/explore/visTypes.js | 99 ++++++++++++++++++++-
superset/assets/src/visualizations/index.js | 3 +
superset/assets/src/visualizations/line_multi.js | 74 +++++++++++++++
superset/assets/src/visualizations/nvd3_vis.js | 70 ++++++++++++---
superset/cli.py | 3 +
superset/data/__init__.py | 28 ++++++
superset/viz.py | 29 ++++++
tests/core_tests.py | 2 +-
13 files changed, 342 insertions(+), 18 deletions(-)
diff --git a/superset/assets/images/viz_thumbnails/line_multi.png b/superset/assets/images/viz_thumbnails/line_multi.png
new file mode 100644
index 0000000..f776bb8
Binary files /dev/null and b/superset/assets/images/viz_thumbnails/line_multi.png differ
diff --git a/superset/assets/images/viz_thumbnails_large/line_multi.png b/superset/assets/images/viz_thumbnails_large/line_multi.png
new file mode 100644
index 0000000..473be99
Binary files /dev/null and b/superset/assets/images/viz_thumbnails_large/line_multi.png differ
diff --git a/superset/assets/src/explore/components/ExploreViewContainer.jsx b/superset/assets/src/explore/components/ExploreViewContainer.jsx
index c8d7acd..bc875d4 100644
--- a/superset/assets/src/explore/components/ExploreViewContainer.jsx
+++ b/superset/assets/src/explore/components/ExploreViewContainer.jsx
@@ -74,7 +74,12 @@ class ExploreViewContainer extends React.Component {
this.props.actions.resetControls();
this.props.actions.triggerQuery(true, this.props.chart.chartKey);
}
- if (np.controls.datasource.value !== this.props.controls.datasource.value) {
+ if (
+ np.controls.datasource && (
+ this.props.controls.datasource == null ||
+ np.controls.datasource.value !== this.props.controls.datasource.value
+ )
+ ) {
this.props.actions.fetchDatasourceMetadata(np.form_data.datasource, true);
}
diff --git a/superset/assets/src/explore/components/controls/DatasourceControl.jsx b/superset/assets/src/explore/components/controls/DatasourceControl.jsx
index 0256aac..404ba5e 100644
--- a/superset/assets/src/explore/components/controls/DatasourceControl.jsx
+++ b/superset/assets/src/explore/components/controls/DatasourceControl.jsx
@@ -18,7 +18,7 @@ const propTypes = {
name: PropTypes.string.isRequired,
onChange: PropTypes.func,
value: PropTypes.string.isRequired,
- datasource: PropTypes.object.isRequired,
+ datasource: PropTypes.object,
};
const defaultProps = {
diff --git a/superset/assets/src/explore/controls.jsx b/superset/assets/src/explore/controls.jsx
index 66d563b..710d6b4 100644
--- a/superset/assets/src/explore/controls.jsx
+++ b/superset/assets/src/explore/controls.jsx
@@ -2068,6 +2068,49 @@ export const controls = {
description: t('The width of the lines'),
},
+ line_charts: {
+ type: 'SelectAsyncControl',
+ multi: true,
+ label: t('Line charts'),
+ validators: [v.nonEmpty],
+ default: [],
+ description: t('Pick a set of line charts to layer on top of one another'),
+ dataEndpoint: '/sliceasync/api/read?_flt_0_viz_type=line&_flt_7_viz_type=line_multi',
+ placeholder: t('Select charts'),
+ onAsyncErrorMessage: t('Error while fetching charts'),
+ mutator: (data) => {
+ if (!data || !data.result) {
+ return [];
+ }
+ return data.result.map(o => ({ value: o.id, label: o.slice_name }));
+ },
+ },
+
+ line_charts_2: {
+ type: 'SelectAsyncControl',
+ multi: true,
+ label: t('Right Axis chart(s)'),
+ validators: [],
+ default: [],
+ description: t('Choose one or more charts for right axis'),
+ dataEndpoint: '/sliceasync/api/read?_flt_0_viz_type=line&_flt_7_viz_type=line_multi',
+ placeholder: t('Select charts'),
+ onAsyncErrorMessage: t('Error while fetching charts'),
+ mutator: (data) => {
+ if (!data || !data.result) {
+ return [];
+ }
+ return data.result.map(o => ({ value: o.id, label: o.slice_name }));
+ },
+ },
+
+ prefix_metric_with_slice_name: {
+ type: 'CheckboxControl',
+ label: t('Prefix metric name with slice name'),
+ default: false,
+ renderTrigger: true,
+ },
+
reverse_long_lat: {
type: 'CheckboxControl',
label: t('Reverse Lat & Long'),
diff --git a/superset/assets/src/explore/visTypes.js b/superset/assets/src/explore/visTypes.js
index 35e49d4..1ad5c89 100644
--- a/superset/assets/src/explore/visTypes.js
+++ b/superset/assets/src/explore/visTypes.js
@@ -225,6 +225,82 @@ export const visTypes = {
},
},
+ line_multi: {
+ label: t('Time Series - Multiple Line Charts'),
+ showOnExplore: true,
+ requiresTime: true,
+ controlPanelSections: [
+ {
+ label: t('Chart Options'),
+ expanded: true,
+ controlSetRows: [
+ ['color_scheme'],
+ ['prefix_metric_with_slice_name', null],
+ ['show_legend', 'show_markers'],
+ ['line_interpolation', null],
+ ],
+ },
+ {
+ label: t('X Axis'),
+ expanded: true,
+ controlSetRows: [
+ ['x_axis_label', 'bottom_margin'],
+ ['x_ticks_layout', 'x_axis_format'],
+ ['x_axis_showminmax', null],
+ ],
+ },
+ {
+ label: t('Y Axis 1'),
+ expanded: true,
+ controlSetRows: [
+ ['line_charts', 'y_axis_format'],
+ ],
+ },
+ {
+ label: t('Y Axis 2'),
+ expanded: false,
+ controlSetRows: [
+ ['line_charts_2', 'y_axis_2_format'],
+ ],
+ },
+ sections.annotations,
+ ],
+ controlOverrides: {
+ line_charts: {
+ label: t('Left Axis chart(s)'),
+ description: t('Choose one or more charts for left axis'),
+ },
+ y_axis_format: {
+ label: t('Left Axis Format'),
+ },
+ x_axis_format: {
+ choices: D3_TIME_FORMAT_OPTIONS,
+ default: 'smart_date',
+ },
+ },
+ sectionOverrides: {
+ sqlClause: [],
+ filters: [[]],
+ datasourceAndVizType: {
+ label: t('Chart Type'),
+ controlSetRows: [
+ ['viz_type'],
+ ['slice_id', 'cache_timeout'],
+ ],
+ },
+ sqlaTimeSeries: {
+ controlSetRows: [
+ ['since', 'until'],
+ ],
+ },
+ druidTimeSeries: {
+ controlSetRows: [
+ ['since', 'until'],
+ ],
+ },
+ },
+ },
+
time_pivot: {
label: t('Time Series - Periodicity Pivot'),
showOnExplore: true,
@@ -1731,11 +1807,26 @@ function adhocFilterEnabled(viz) {
export function sectionsToRender(vizType, datasourceType) {
const viz = visTypes[vizType];
+
+ const sectionsCopy = { ...sections };
+ if (viz.sectionOverrides) {
+ Object.entries(viz.sectionOverrides).forEach(([section, overrides]) => {
+ if (typeof overrides === 'object' && overrides.constructor === Object) {
+ sectionsCopy[section] = {
+ ...sectionsCopy[section],
+ ...overrides,
+ };
+ } else {
+ sectionsCopy[section] = overrides;
+ }
+ });
+ }
+
return [].concat(
- sections.datasourceAndVizType,
- datasourceType === 'table' ? sections.sqlaTimeSeries : sections.druidTimeSeries,
+ sectionsCopy.datasourceAndVizType,
+ datasourceType === 'table' ? sectionsCopy.sqlaTimeSeries : sectionsCopy.druidTimeSeries,
viz.controlPanelSections,
- !adhocFilterEnabled(viz) && (datasourceType === 'table' ? sections.sqlClause : []),
- !adhocFilterEnabled(viz) && (datasourceType === 'table' ? sections.filters[0] : sections.filters),
+ !adhocFilterEnabled(viz) && (datasourceType === 'table' ? sectionsCopy.sqlClause : []),
+ !adhocFilterEnabled(viz) && (datasourceType === 'table' ? sectionsCopy.filters[0] : sectionsCopy.filters),
).filter(section => section);
}
diff --git a/superset/assets/src/visualizations/index.js b/superset/assets/src/visualizations/index.js
index 5477c33..77cc0ea 100644
--- a/superset/assets/src/visualizations/index.js
+++ b/superset/assets/src/visualizations/index.js
@@ -1,5 +1,6 @@
/* eslint-disable global-require */
import nvd3Vis from './nvd3_vis';
+import lineMulti from './line_multi';
// You ***should*** use these to reference viz_types in code
export const VIZ_TYPES = {
@@ -21,6 +22,7 @@ export const VIZ_TYPES = {
horizon: 'horizon',
iframe: 'iframe',
line: 'line',
+ line_multi: 'line_multi',
mapbox: 'mapbox',
markup: 'markup',
para: 'para',
@@ -71,6 +73,7 @@ const vizMap = {
[VIZ_TYPES.horizon]: require('./horizon.js'),
[VIZ_TYPES.iframe]: require('./iframe.js'),
[VIZ_TYPES.line]: nvd3Vis,
+ [VIZ_TYPES.line_multi]: lineMulti,
[VIZ_TYPES.time_pivot]: nvd3Vis,
[VIZ_TYPES.mapbox]: require('./mapbox.jsx'),
[VIZ_TYPES.markup]: require('./markup.js'),
diff --git a/superset/assets/src/visualizations/line_multi.js b/superset/assets/src/visualizations/line_multi.js
new file mode 100644
index 0000000..c164686
--- /dev/null
+++ b/superset/assets/src/visualizations/line_multi.js
@@ -0,0 +1,74 @@
+import nvd3Vis from './nvd3_vis';
+import { getExploreLongUrl } from '../explore/exploreUtils';
+
+
+export default function lineMulti(slice, payload) {
+ /*
+ * Show multiple line charts
+ *
+ * This visualization works by fetching the data from each of the saved
+ * charts, building the payload data and passing it along to nvd3Vis.
+ */
+ const fd = slice.formData;
+
+ // fetch data from all the charts
+ const promises = [];
+ const subslices = [
+ ...payload.data.slices.axis1.map(subslice => [1, subslice]),
+ ...payload.data.slices.axis2.map(subslice => [2, subslice]),
+ ];
+ subslices.forEach(([yAxis, subslice]) => {
+ let filters = subslice.form_data.filters || [];
+ filters.concat(fd.filters);
+ if (fd.extra_filters) {
+ filters = filters.concat(fd.extra_filters);
+ }
+ const fdCopy = {
+ ...subslice.form_data,
+ filters,
+ since: fd.since,
+ until: fd.until,
+ };
+ const url = getExploreLongUrl(fdCopy, 'json');
+ promises.push(new Promise((resolve, reject) => {
+ d3.json(url, (error, response) => {
+ if (error) {
+ reject(error);
+ } else {
+ const data = [];
+ response.data.forEach((datum) => {
+ let key = datum.key;
+ if (fd.prefix_metric_with_slice_name) {
+ key = subslice.slice_name + ': ' + key;
+ }
+ data.push({ key, values: datum.values, type: fdCopy.viz_type, yAxis });
+ });
+ resolve(data);
+ }
+ });
+ }));
+ });
+
+ Promise.all(promises).then((data) => {
+ const payloadCopy = { ...payload };
+ payloadCopy.data = [].concat(...data);
+
+ // add null values at the edges to fix multiChart bug when series with
+ // different x values use different y axes
+ if (fd.line_charts.length && fd.line_charts_2.length) {
+ let minx = Infinity;
+ let maxx = -Infinity;
+ payloadCopy.data.forEach((datum) => {
+ minx = Math.min(minx, ...datum.values.map(v => v.x));
+ maxx = Math.max(maxx, ...datum.values.map(v => v.x));
+ });
+ // add null values at the edges
+ payloadCopy.data.forEach((datum) => {
+ datum.values.push({ x: minx, y: null });
+ datum.values.push({ x: maxx, y: null });
+ });
+ }
+
+ nvd3Vis(slice, payloadCopy);
+ });
+}
diff --git a/superset/assets/src/visualizations/nvd3_vis.js b/superset/assets/src/visualizations/nvd3_vis.js
index 068b8a0..c2c74a0 100644
--- a/superset/assets/src/visualizations/nvd3_vis.js
+++ b/superset/assets/src/visualizations/nvd3_vis.js
@@ -32,6 +32,16 @@ const BREAKPOINTS = {
small: 340,
};
+const TIMESERIES_VIZ_TYPES = [
+ 'line',
+ 'dual_line',
+ 'line_multi',
+ 'area',
+ 'compare',
+ 'bar',
+ 'time_pivot',
+];
+
const addTotalBarValues = function (svg, chart, data, stacked, axisFormat) {
const format = d3.format(axisFormat || '.3s');
const countSeriesDisplayed = data.length;
@@ -149,8 +159,7 @@ export default function nvd3Vis(slice, payload) {
svg = d3.select(slice.selector).append('svg');
}
let height = slice.height();
- const isTimeSeries = [
- 'line', 'dual_line', 'area', 'compare', 'bar', 'time_pivot'].indexOf(vizType) >= 0;
+ const isTimeSeries = TIMESERIES_VIZ_TYPES.indexOf(vizType) >= 0;
// Handling xAxis ticks settings
let xLabelRotation = 0;
@@ -202,6 +211,11 @@ export default function nvd3Vis(slice, payload) {
chart.interpolate('linear');
break;
+ case 'line_multi':
+ chart = nv.models.multiChart();
+ chart.interpolate(fd.line_interpolation);
+ break;
+
case 'bar':
chart = nv.models.multiBarChart()
.showControls(fd.show_controls)
@@ -461,13 +475,19 @@ export default function nvd3Vis(slice, payload) {
}
}
- if (vizType === 'dual_line') {
+ if (['dual_line', 'line_multi'].indexOf(vizType) >= 0) {
const yAxisFormatter1 = d3.format(fd.y_axis_format);
const yAxisFormatter2 = d3.format(fd.y_axis_2_format);
chart.yAxis1.tickFormat(yAxisFormatter1);
chart.yAxis2.tickFormat(yAxisFormatter2);
- customizeToolTip(chart, xAxisFormatter, [yAxisFormatter1, yAxisFormatter2]);
- chart.showLegend(width > BREAKPOINTS.small);
+ const yAxisFormatters = data.map(datum => (
+ datum.yAxis === 1 ? yAxisFormatter1 : yAxisFormatter2));
+ customizeToolTip(chart, xAxisFormatter, yAxisFormatters);
+ if (vizType === 'dual_line') {
+ chart.showLegend(width > BREAKPOINTS.small);
+ } else {
+ chart.showLegend(fd.show_legend);
+ }
}
chart.height(height);
slice.container.css('height', height + 'px');
@@ -479,6 +499,31 @@ export default function nvd3Vis(slice, payload) {
.attr('width', width)
.call(chart);
+ // align yAxis1 and yAxis2 ticks
+ if (['dual_line', 'line_multi'].indexOf(vizType) >= 0) {
+ const count = chart.yAxis1.ticks();
+ const ticks1 = chart.yAxis1.scale().domain(chart.yAxis1.domain()).nice(count).ticks(count);
+ const ticks2 = chart.yAxis2.scale().domain(chart.yAxis2.domain()).nice(count).ticks(count);
+
+ // match number of ticks in both axes
+ const difference = ticks1.length - ticks2.length;
+ if (ticks1.length && ticks2.length && difference !== 0) {
+ const smallest = difference < 0 ? ticks1 : ticks2;
+ const delta = smallest[1] - smallest[0];
+ for (let i = 0; i < Math.abs(difference); i++) {
+ if (i % 2 === 0) {
+ smallest.unshift(smallest[0] - delta);
+ } else {
+ smallest.push(smallest[smallest.length - 1] + delta);
+ }
+ }
+ chart.yDomain1([ticks1[0], ticks1[ticks1.length - 1]]);
+ chart.yDomain2([ticks2[0], ticks2[ticks2.length - 1]]);
+ chart.yAxis1.tickValues(ticks1);
+ chart.yAxis2.tickValues(ticks2);
+ }
+ }
+
if (fd.show_markers) {
svg.selectAll('.nv-point')
.style('stroke-opacity', 1)
@@ -516,12 +561,9 @@ export default function nvd3Vis(slice, payload) {
margins.bottom = 40;
}
- if (vizType === 'dual_line') {
+ if (['dual_line', 'line_multi'].indexOf(vizType) >= 0) {
const maxYAxis2LabelWidth = getMaxLabelSize(slice.container, 'nv-y2');
- // use y axis width if it's wider than axis width/height
- if (maxYAxis2LabelWidth > maxXAxisLabelHeight) {
- margins.right = maxYAxis2LabelWidth + marginPad;
- }
+ margins.right = maxYAxis2LabelWidth + marginPad;
}
if (fd.bottom_margin && fd.bottom_margin !== 'auto') {
margins.bottom = parseInt(fd.bottom_margin, 10);
@@ -600,7 +642,13 @@ export default function nvd3Vis(slice, payload) {
} else {
xMin = chart.xAxis.scale().domain()[0].valueOf();
xMax = chart.xAxis.scale().domain()[1].valueOf();
- xScale = chart.xScale ? chart.xScale() : d3.scale.linear();
+ if (chart.xScale) {
+ xScale = chart.xScale();
+ } else if (chart.xAxis.scale) {
+ xScale = chart.xAxis.scale();
+ } else {
+ xScale = d3.scale.linear();
+ }
}
if (xScale && xScale.clamp) {
xScale.clamp(true);
diff --git a/superset/cli.py b/superset/cli.py
index d31e003..a304664 100755
--- a/superset/cli.py
+++ b/superset/cli.py
@@ -186,6 +186,9 @@ def load_examples(load_test_data):
print('Loading [BART lines]')
data.load_bart_lines()
+ print('Loading [Multi Line]')
+ data.load_multi_line()
+
if load_test_data:
print('Loading [Unicode test data]')
data.load_unicode_test_data()
diff --git a/superset/data/__init__.py b/superset/data/__init__.py
index d3d7da8..8451b95 100644
--- a/superset/data/__init__.py
+++ b/superset/data/__init__.py
@@ -1864,3 +1864,31 @@ def load_bart_lines():
db.session.merge(tbl)
db.session.commit()
tbl.fetch_metadata()
+
+
+def load_multi_line():
+ load_world_bank_health_n_pop()
+ load_birth_names()
+ ids = [
+ row.id for row in
+ db.session.query(Slice).filter(
+ Slice.slice_name.in_(['Growth Rate', 'Trends']))
+ ]
+
+ slc = Slice(
+ datasource_type='table', # not true, but needed
+ datasource_id=1, # cannot be empty
+ slice_name="Multi Line",
+ viz_type='line_multi',
+ params=json.dumps({
+ "slice_name": "Multi Line",
+ "viz_type": "line_multi",
+ "line_charts": [ids[0]],
+ "line_charts_2": [ids[1]],
+ "since": "1960-01-01",
+ "prefix_metric_with_slice_name": True,
+ }),
+ )
+
+ misc_dash_slices.append(slc.slice_name)
+ merge_slice(slc)
diff --git a/superset/viz.py b/superset/viz.py
index 39d3411..1236dd8 100644
--- a/superset/viz.py
+++ b/superset/viz.py
@@ -1269,6 +1269,35 @@ class NVD3TimeSeriesViz(NVD3Viz):
return chart_data
+class MultiLineViz(NVD3Viz):
+
+ """Pile on multiple line charts"""
+
+ viz_type = 'line_multi'
+ verbose_name = _('Time Series - Multiple Line Charts')
+
+ is_timeseries = True
+
+ def query_obj(self):
+ return None
+
+ def get_data(self, df):
+ fd = self.form_data
+ # Late imports to avoid circular import issues
+ from superset.models.core import Slice
+ from superset import db
+ slice_ids1 = fd.get('line_charts')
+ slices1 = db.session.query(Slice).filter(Slice.id.in_(slice_ids1)).all()
+ slice_ids2 = fd.get('line_charts_2')
+ slices2 = db.session.query(Slice).filter(Slice.id.in_(slice_ids2)).all()
+ return {
+ 'slices': {
+ 'axis1': [slc.data for slc in slices1],
+ 'axis2': [slc.data for slc in slices2],
+ },
+ }
+
+
class NVD3DualLineViz(NVD3Viz):
"""A rich line chart with dual axis"""
diff --git a/tests/core_tests.py b/tests/core_tests.py
index 4b0fc46..c454c00 100644
--- a/tests/core_tests.py
+++ b/tests/core_tests.py
@@ -370,7 +370,7 @@ class CoreTests(SupersetTestCase):
data = self.get_json_resp(
'/superset/warm_up_cache?table_name=energy_usage&db_name=main')
- assert len(data) == 3
+ assert len(data) == 4
def test_shortner(self):
self.login(username='admin')
--
To stop receiving notification emails like this one, please contact
maximebeauchemin@apache.org.