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 0000000000000..f776bb847b48c 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 0000000000000..473be99b21822 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 c8d7acd82ef50..bc875d4886f68 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 0256aacc38c9c..404ba5e3296e5 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 66d563b343e68..710d6b4c35680 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 35e49d4587b60..1ad5c895e6f18 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 5477c337b1e03..77cc0ea8d49df 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 0000000000000..c164686ce5202 --- /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 068b8a0b55693..c2c74a0b7eb1f 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 d31e0033fbe52..a304664f8e558 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 d3d7da86417c0..8451b95631c15 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 39d3411456a2d..1236dd85d1236 100644 --- a/superset/viz.py +++ b/superset/viz.py @@ -1269,6 +1269,35 @@ def get_data(self, df): 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 4b0fc461805cc..c454c008495d1 100644 --- a/tests/core_tests.py +++ b/tests/core_tests.py @@ -370,7 +370,7 @@ def test_warm_up_cache(self): 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')