diff --git a/superset/assets/images/viz_thumbnails/deck_multi.png b/superset/assets/images/viz_thumbnails/deck_multi.png new file mode 100644 index 0000000000000..21c27c048997b Binary files /dev/null and b/superset/assets/images/viz_thumbnails/deck_multi.png differ diff --git a/superset/assets/javascripts/chart/Chart.jsx b/superset/assets/javascripts/chart/Chart.jsx index bd7e4f80c2b7b..e1502a3a2114c 100644 --- a/superset/assets/javascripts/chart/Chart.jsx +++ b/superset/assets/javascripts/chart/Chart.jsx @@ -2,6 +2,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import Mustache from 'mustache'; +import { Tooltip } from 'react-bootstrap'; import { d3format } from '../modules/utils'; import ChartBody from './ChartBody'; @@ -9,6 +10,7 @@ import Loading from '../components/Loading'; import StackTraceMessage from '../components/StackTraceMessage'; import visMap from '../../visualizations/main'; import sandboxedEval from '../modules/sandbox'; +import './chart.css'; const propTypes = { annotationData: PropTypes.object, @@ -49,6 +51,7 @@ const defaultProps = { class Chart extends React.PureComponent { constructor(props) { super(props); + this.state = {}; // these properties are used by visualizations this.annotationData = props.annotationData; this.containerId = props.containerId; @@ -99,6 +102,10 @@ class Chart extends React.PureComponent { return this.props.getFilters(); } + setTooltip(tooltip) { + this.setState({ tooltip }); + } + addFilter(col, vals, merge = true, refresh = true) { this.props.addFilter(col, vals, merge, refresh); } @@ -140,6 +147,26 @@ class Chart extends React.PureComponent { return Mustache.render(s, context); } + renderTooltip() { + if (this.state.tooltip) { + /* eslint-disable react/no-danger */ + return ( + +
+ + ); + /* eslint-enable react/no-danger */ + } + return null; + } + renderViz() { const viz = visMap[this.props.vizType]; const fd = this.props.formData; @@ -160,10 +187,10 @@ class Chart extends React.PureComponent { const isLoading = this.props.chartStatus === 'loading'; return (
+ {this.renderTooltip()} {isLoading && } - {this.props.chartAlert && - {t('While this runs in a ')} - sandboxed vm - , {t('a set of')} useful objects are in context - {t('to be used where necessary.')} - ); - const groupByControl = { type: 'SelectControl', multi: true, @@ -77,6 +68,35 @@ const groupByControl = { }, }; +const sandboxUrl = ( + 'https://github.com/apache/incubator-superset/' + + 'blob/master/superset/assets/javascripts/modules/sandbox.js'); +const jsFunctionInfo = ( +
+ {t('For more information about objects are in context in the scope of this function, refer to the')} + + {t(" source code of Superset's sandboxed parser")}. + . +
+); +function jsFunctionControl(label, description, extraDescr = null, height = 100, defaultText = '') { + return { + type: 'TextAreaControl', + language: 'javascript', + label, + description, + height, + default: defaultText, + aboveEditorSection: ( +
+

{description}

+

{jsFunctionInfo}

+ {extraDescr} +
+ ), + }; +} + export const controls = { datasource: { type: 'DatasourceControl', @@ -1181,14 +1201,14 @@ export const controls = { type: 'CheckboxControl', label: t('Range Filter'), renderTrigger: true, - default: false, + default: true, description: t('Whether to display the time range interactive selector'), }, date_filter: { type: 'CheckboxControl', label: t('Date Filter'), - default: false, + default: true, description: t('Whether to include a time filter'), }, @@ -1399,7 +1419,7 @@ export const controls = { ['mapbox://styles/mapbox/satellite-v9', 'Satellite'], ['mapbox://styles/mapbox/outdoors-v9', 'Outdoors'], ], - default: 'mapbox://styles/mapbox/streets-v9', + default: 'mapbox://styles/mapbox/light-v9', description: t('Base layer map style'), }, @@ -1804,20 +1824,6 @@ export const controls = { default: false, }, - js_data: { - type: 'TextAreaControl', - label: t('Javascript data mutator'), - description: t('Define a function that receives intercepts the data objects and can mutate it'), - language: 'javascript', - default: '', - height: 100, - aboveEditorSection: ( -

- Define a function that intercepts the data object passed to the visualization - and returns a similarly shaped object. {sandboxedEvalInfo} -

), - }, - deck_slices: { type: 'SelectAsyncControl', multi: true, @@ -1835,5 +1841,49 @@ export const controls = { return data.result.map(o => ({ value: o.id, label: o.slice_name })); }, }, + + js_datapoint_mutator: jsFunctionControl( + t('Javascript data point mutator'), + t('Define a javascript function that receives each data point and can alter it ' + + 'before getting sent to the deck.gl layer'), + ), + + js_data: jsFunctionControl( + t('Javascript data mutator'), + t('Define a function that receives intercepts the data objects and can mutate it'), + ), + + js_tooltip: jsFunctionControl( + t('Javascript tooltip generator'), + t('Define a function that receives the input and outputs the content for a tooltip'), + ), + + js_onclick_href: jsFunctionControl( + t('Javascript onClick href'), + t('Define a function that returns a URL to navigate to when user clicks'), + ), + + js_columns: { + ...groupByControl, + label: t('Extra data for JS'), + default: [], + description: t('List of extra columns made available in Javascript functions'), + }, + + stroked: { + type: 'CheckboxControl', + label: t('Stroked'), + renderTrigger: true, + description: t('Whether to display the stroke'), + default: false, + }, + + filled: { + type: 'CheckboxControl', + label: t('Filled'), + renderTrigger: true, + description: t('Whether to fill the objects'), + default: false, + }, }; export default controls; diff --git a/superset/assets/javascripts/explore/stores/visTypes.js b/superset/assets/javascripts/explore/stores/visTypes.js index f2e668f8f1829..0be54ec21197d 100644 --- a/superset/assets/javascripts/explore/stores/visTypes.js +++ b/superset/assets/javascripts/explore/stores/visTypes.js @@ -433,6 +433,15 @@ export const visTypes = { ['reverse_long_lat', null], ], }, + { + label: t('Advanced'), + controlSetRows: [ + ['js_columns'], + ['js_datapoint_mutator'], + ['js_tooltip'], + ['js_onclick_href'], + ], + }, ], }, @@ -491,9 +500,20 @@ export const visTypes = { label: t('GeoJson Settings'), controlSetRows: [ ['fill_color_picker', 'stroke_color_picker'], + ['filled', 'stroked'], + ['extruded', null], ['point_radius_scale', null], ], }, + { + label: t('Advanced'), + controlSetRows: [ + ['js_columns'], + ['js_datapoint_mutator'], + ['js_tooltip'], + ['js_onclick_href'], + ], + }, ], }, @@ -529,6 +549,15 @@ export const visTypes = { ['dimension', 'color_scheme'], ], }, + { + label: t('Advanced'), + controlSetRows: [ + ['js_columns'], + ['js_datapoint_mutator'], + ['js_tooltip'], + ['js_onclick_href'], + ], + }, ], controlOverrides: { dimension: { diff --git a/superset/assets/javascripts/modules/sandbox.js b/superset/assets/javascripts/modules/sandbox.js index 24473adb3d644..3439c0319f9f6 100644 --- a/superset/assets/javascripts/modules/sandbox.js +++ b/superset/assets/javascripts/modules/sandbox.js @@ -1,6 +1,7 @@ // A safe alternative to JS's eval import vm from 'vm'; import _ from 'underscore'; +import * as colors from './colors'; // Objects exposed here should be treated like a public API // if `underscore` had backwards incompatible changes in a future release, we'd @@ -8,6 +9,7 @@ import _ from 'underscore'; const GLOBAL_CONTEXT = { console, _, + colors, }; // Copied/modified from https://github.com/hacksparrow/safe-eval/blob/master/index.js diff --git a/superset/assets/package.json b/superset/assets/package.json index 905e770294b06..943e0484d7f43 100644 --- a/superset/assets/package.json +++ b/superset/assets/package.json @@ -56,15 +56,16 @@ "d3-tip": "^0.6.7", "datamaps": "^0.5.8", "datatables.net-bs": "^1.10.15", - "deck.gl": "^4.1.5", + "deck.gl": "^5.0.1", "distributions": "^1.0.0", + "dompurify": "^1.0.3", "fastdom": "^1.0.6", "geolib": "^2.0.24", "immutable": "^3.8.2", "jed": "^1.1.1", "jquery": "3.1.1", "lodash.throttle": "^4.1.1", - "luma.gl": "^4.0.5", + "luma.gl": "^5.0.1", "mathjs": "^3.16.3", "moment": "2.18.1", "mustache": "^2.2.1", diff --git a/superset/assets/visualizations/deckgl/factory.jsx b/superset/assets/visualizations/deckgl/factory.jsx index d715bc1a9cbe0..fa6c37297954f 100644 --- a/superset/assets/visualizations/deckgl/factory.jsx +++ b/superset/assets/visualizations/deckgl/factory.jsx @@ -6,7 +6,7 @@ import layerGenerators from './layers'; export default function deckglFactory(slice, payload, setControlValue) { const fd = slice.formData; - const layer = layerGenerators[fd.viz_type](fd, payload); + const layer = layerGenerators[fd.viz_type](fd, payload, slice); const viewport = { ...fd.viewport, width: slice.width(), diff --git a/superset/assets/visualizations/deckgl/layers/common.js b/superset/assets/visualizations/deckgl/layers/common.js new file mode 100644 index 0000000000000..7f11213b6c3e1 --- /dev/null +++ b/superset/assets/visualizations/deckgl/layers/common.js @@ -0,0 +1,33 @@ +import dompurify from 'dompurify'; +import sandboxedEval from '../../../javascripts/modules/sandbox'; + +export function commonLayerProps(formData, slice) { + const fd = formData; + let onHover; + if (fd.js_tooltip) { + const jsTooltip = sandboxedEval(fd.js_tooltip); + onHover = (o) => { + if (o.picked) { + slice.setTooltip({ + content: dompurify.sanitize(jsTooltip(o)), + x: o.x, + y: o.y, + }); + } else { + slice.setTooltip(null); + } + }; + } + let onClick; + if (fd.js_onclick_href) { + onClick = (o) => { + const href = sandboxedEval(fd.js_onclick_href)(o); + window.open(href); + }; + } + return { + onClick, + onHover, + pickable: Boolean(onHover), + }; +} diff --git a/superset/assets/visualizations/deckgl/layers/geojson.jsx b/superset/assets/visualizations/deckgl/layers/geojson.jsx index 11a7b8375f5b2..3ee1f62830a95 100644 --- a/superset/assets/visualizations/deckgl/layers/geojson.jsx +++ b/superset/assets/visualizations/deckgl/layers/geojson.jsx @@ -1,6 +1,8 @@ import { GeoJsonLayer } from 'deck.gl'; -import { hexToRGB } from '../../../javascripts/modules/colors'; +import * as common from './common'; +import { hexToRGB } from '../../../javascripts/modules/colors'; +import sandboxedEval from '../../../javascripts/modules/sandbox'; const propertyMap = { fillColor: 'fillColor', @@ -23,11 +25,11 @@ const convertGeoJsonColorProps = (p, colors) => { }; }; -export default function geoJsonLayer(formData, payload) { +export default function geoJsonLayer(formData, payload, slice) { const fd = formData; const fc = fd.fill_color_picker; const sc = fd.stroke_color_picker; - const data = payload.data.geojson.features.map(d => ({ + let data = payload.data.geojson.features.map(d => ({ ...d, properties: convertGeoJsonColorProps( d.properties, { @@ -36,12 +38,19 @@ export default function geoJsonLayer(formData, payload) { }), })); + if (fd.js_datapoint_mutator) { + // Applying user defined data mutator if defined + const jsFnMutator = sandboxedEval(fd.js_datapoint_mutator); + data = data.map(jsFnMutator); + } + return new GeoJsonLayer({ id: `path-layer-${fd.slice_id}`, data, - filled: true, - stroked: false, - extruded: true, + filled: fd.filled, + stroked: fd.stroked, + extruded: fd.extruded, pointRadiusScale: fd.point_radius_scale, + ...common.commonLayerProps(fd, slice), }); } diff --git a/superset/assets/visualizations/deckgl/layers/path.jsx b/superset/assets/visualizations/deckgl/layers/path.jsx index c288ff0576a8e..c69f23634a4be 100644 --- a/superset/assets/visualizations/deckgl/layers/path.jsx +++ b/superset/assets/visualizations/deckgl/layers/path.jsx @@ -1,19 +1,29 @@ import { PathLayer } from 'deck.gl'; -export default function getLayer(formData, payload) { +import * as common from './common'; +import sandboxedEval from '../../../javascripts/modules/sandbox'; + +export default function getLayer(formData, payload, slice) { const fd = formData; const c = fd.color_picker; const fixedColor = [c.r, c.g, c.b, 255 * c.a]; - const data = payload.data.paths.map(path => ({ - path, + let data = payload.data.features.map(feature => ({ + ...feature, + path: feature.path, width: fd.line_width, color: fixedColor, })); + if (fd.js_datapoint_mutator) { + const jsFnMutator = sandboxedEval(fd.js_datapoint_mutator); + data = data.map(jsFnMutator); + } + return new PathLayer({ id: `path-layer-${fd.slice_id}`, data, rounded: true, widthScale: 1, + ...common.commonLayerProps(fd, slice), }); } diff --git a/superset/assets/visualizations/deckgl/layers/scatter.jsx b/superset/assets/visualizations/deckgl/layers/scatter.jsx index d44e7272adb97..eda1b7c361f85 100644 --- a/superset/assets/visualizations/deckgl/layers/scatter.jsx +++ b/superset/assets/visualizations/deckgl/layers/scatter.jsx @@ -1,14 +1,16 @@ import { ScatterplotLayer } from 'deck.gl'; +import * as common from './common'; import { getColorFromScheme, hexToRGB } from '../../../javascripts/modules/colors'; import { unitToRadius } from '../../../javascripts/modules/geo'; +import sandboxedEval from '../../../javascripts/modules/sandbox'; -export default function getLayer(formData, payload) { +export default function getLayer(formData, payload, slice) { const fd = formData; const c = fd.color_picker || { r: 0, g: 0, b: 0, a: 1 }; const fixedColor = [c.r, c.g, c.b, 255 * c.a]; - const data = payload.data.features.map((d) => { + let data = payload.data.features.map((d) => { let radius = unitToRadius(fd.point_unit, d.radius) || 10; if (fd.multiplier) { radius *= fd.multiplier; @@ -25,11 +27,18 @@ export default function getLayer(formData, payload) { color, }; }); + + if (fd.js_datapoint_mutator) { + // Applying user defined data mutator if defined + const jsFnMutator = sandboxedEval(fd.js_datapoint_mutator); + data = data.map(jsFnMutator); + } + return new ScatterplotLayer({ id: `scatter-layer-${fd.slice_id}`, data, - pickable: true, fp64: true, outline: false, + ...common.commonLayerProps(fd, slice), }); } diff --git a/superset/viz.py b/superset/viz.py index 8bb65824d1a5b..6f4d76c66c4a7 100644 --- a/superset/viz.py +++ b/superset/viz.py @@ -1841,6 +1841,8 @@ def query_obj(self): if fd.get('dimension'): gb += [fd.get('dimension')] + if fd.get('js_columns'): + gb += fd.get('js_columns') metrics = self.get_metrics() if metrics: d['groupby'] = gb @@ -1849,6 +1851,10 @@ def query_obj(self): d['columns'] = gb return d + def get_js_columns(self, d): + cols = self.form_data.get('js_columns') or [] + return {col: d.get(col) for col in cols} + def get_data(self, df): fd = self.form_data spatial = fd.get('spatial') @@ -1876,8 +1882,11 @@ def get_data(self, df): features = [] for d in df.to_dict(orient='records'): - d = dict(position=self.get_position(d), **self.get_properties(d)) - features.append(d) + feature = dict( + position=self.get_position(d), + props=self.get_js_columns(d), + **self.get_properties(d)) + features.append(feature) return { 'features': features, 'mapboxApiKey': config.get('MAPBOX_API_KEY'), @@ -1949,22 +1958,22 @@ class DeckPathViz(BaseDeckGLViz): def query_obj(self): d = super(DeckPathViz, self).query_obj() - d['groupby'] = [] - d['metrics'] = [] - d['columns'] = [self.form_data.get('line_column')] + line_col = self.form_data.get('line_column') + if d['metrics']: + d['groupby'].append(line_col) + else: + d['columns'].append(line_col) return d - def get_data(self, df): + def get_properties(self, d): fd = self.form_data deser = self.deser_map[fd.get('line_type')] - paths = [deser(s) for s in df[fd.get('line_column')]] + path = deser(d[fd.get('line_column')]) if fd.get('reverse_long_lat'): - paths = [[(point[1], point[0]) for point in path] for path in paths] - d = { - 'mapboxApiKey': config.get('MAPBOX_API_KEY'), - 'paths': paths, + path = (path[1], path[0]) + return { + 'path': path, } - return d class DeckHex(BaseDeckGLViz):