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/01/11 23:42:46 UTC
[incubator-superset] branch master updated: Using user-defined
Javascript to customize geospatial visualization (#4173)
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 87c3e83 Using user-defined Javascript to customize geospatial visualization (#4173)
87c3e83 is described below
commit 87c3e831a89da4c32914e8229fbe9e2ed8ffeffb
Author: Maxime Beauchemin <ma...@gmail.com>
AuthorDate: Thu Jan 11 15:42:44 2018 -0800
Using user-defined Javascript to customize geospatial visualization (#4173)
* Using JS to customize spatial viz and tooltips
* Add missing deck_multi.png
* Improve GeoJSON layer with JS support and extra controls
* Addressing comments
---
.../assets/images/viz_thumbnails/deck_multi.png | Bin 0 -> 991412 bytes
superset/assets/javascripts/chart/Chart.jsx | 29 +++++-
superset/assets/javascripts/chart/chart.css | 4 +
.../assets/javascripts/explore/stores/controls.jsx | 102 +++++++++++++++------
.../assets/javascripts/explore/stores/visTypes.js | 29 ++++++
superset/assets/javascripts/modules/sandbox.js | 2 +
superset/assets/package.json | 5 +-
superset/assets/visualizations/deckgl/factory.jsx | 2 +-
.../assets/visualizations/deckgl/layers/common.js | 33 +++++++
.../visualizations/deckgl/layers/geojson.jsx | 21 +++--
.../assets/visualizations/deckgl/layers/path.jsx | 16 +++-
.../visualizations/deckgl/layers/scatter.jsx | 15 ++-
superset/viz.py | 33 ++++---
13 files changed, 237 insertions(+), 54 deletions(-)
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 0000000..21c27c0
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 bd7e4f8..e1502a3 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 (
+ <Tooltip
+ className="chart-tooltip"
+ id="chart-tooltip"
+ placement="right"
+ positionTop={this.state.tooltip.y - 10}
+ positionLeft={this.state.tooltip.x + 30}
+ arrowOffsetTop={10}
+ >
+ <div dangerouslySetInnerHTML={{ __html: this.state.tooltip.content }} />
+ </Tooltip>
+ );
+ /* 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 (
<div className={`token col-md-12 ${isLoading ? 'is-loading' : ''}`}>
+ {this.renderTooltip()}
{isLoading &&
<Loading size={25} />
}
-
{this.props.chartAlert &&
<StackTraceMessage
message={this.props.chartAlert}
diff --git a/superset/assets/javascripts/chart/chart.css b/superset/assets/javascripts/chart/chart.css
new file mode 100644
index 0000000..eda2054
--- /dev/null
+++ b/superset/assets/javascripts/chart/chart.css
@@ -0,0 +1,4 @@
+.chart-tooltip {
+ opacity: 0.75;
+ font-size: 12px;
+}
diff --git a/superset/assets/javascripts/explore/stores/controls.jsx b/superset/assets/javascripts/explore/stores/controls.jsx
index 70cc231..2df229a 100644
--- a/superset/assets/javascripts/explore/stores/controls.jsx
+++ b/superset/assets/javascripts/explore/stores/controls.jsx
@@ -46,15 +46,6 @@ const sortAxisChoices = [
['value_desc', 'sum(value) descending'],
];
-const sandboxUrl = 'https://github.com/apache/incubator-superset/blob/master/superset/assets/javascripts/modules/sandbox.js';
-const sandboxedEvalInfo = (
- <span>
- {t('While this runs in a ')}
- <a href="https://nodejs.org/api/vm.html#vm_script_runinnewcontext_sandbox_options">sandboxed vm</a>
- , {t('a set of')}<a href={sandboxUrl}> useful objects are in context </a>
- {t('to be used where necessary.')}
- </span>);
-
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 = (
+ <div>
+ {t('For more information about objects are in context in the scope of this function, refer to the')}
+ <a href={sandboxUrl}>
+ {t(" source code of Superset's sandboxed parser")}.
+ </a>.
+ </div>
+);
+function jsFunctionControl(label, description, extraDescr = null, height = 100, defaultText = '') {
+ return {
+ type: 'TextAreaControl',
+ language: 'javascript',
+ label,
+ description,
+ height,
+ default: defaultText,
+ aboveEditorSection: (
+ <div>
+ <p>{description}</p>
+ <p>{jsFunctionInfo}</p>
+ {extraDescr}
+ </div>
+ ),
+ };
+}
+
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: (
- <p>
- Define a function that intercepts the <code>data</code> object passed to the visualization
- and returns a similarly shaped object. {sandboxedEvalInfo}
- </p>),
- },
-
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 f2e668f..0be54ec 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 24473ad..3439c03 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 905e770..943e048 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 d715bc1..fa6c372 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 0000000..7f11213
--- /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 11a7b83..3ee1f62 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 c288ff0..c69f236 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 d44e727..eda1b7c 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 8bb6582..6f4d76c 100644
--- a/superset/viz.py
+++ b/superset/viz.py
@@ -1841,6 +1841,8 @@ class BaseDeckGLViz(BaseViz):
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 @@ class BaseDeckGLViz(BaseViz):
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 @@ class BaseDeckGLViz(BaseViz):
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):
--
To stop receiving notification emails like this one, please contact
['"commits@superset.apache.org" <co...@superset.apache.org>'].