Skip to content

Commit

Permalink
Using user-defined Javascript to customize geospatial visualization (a…
Browse files Browse the repository at this point in the history
…pache#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
  • Loading branch information
mistercrunch authored and michellethomas committed May 23, 2018
1 parent 4880c82 commit 59ea9f3
Show file tree
Hide file tree
Showing 13 changed files with 237 additions and 54 deletions.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
29 changes: 28 additions & 1 deletion superset/assets/javascripts/chart/Chart.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@
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';
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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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;
Expand All @@ -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}
Expand Down
4 changes: 4 additions & 0 deletions superset/assets/javascripts/chart/chart.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
.chart-tooltip {
opacity: 0.75;
font-size: 12px;
}
102 changes: 76 additions & 26 deletions superset/assets/javascripts/explore/stores/controls.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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',
Expand Down Expand Up @@ -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'),
},

Expand Down Expand Up @@ -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'),
},

Expand Down Expand Up @@ -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,
Expand All @@ -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;
29 changes: 29 additions & 0 deletions superset/assets/javascripts/explore/stores/visTypes.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
],
},
],
},

Expand Down Expand Up @@ -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'],
],
},
],
},

Expand Down Expand Up @@ -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: {
Expand Down
2 changes: 2 additions & 0 deletions superset/assets/javascripts/modules/sandbox.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
// 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
// have to be careful about bumping the library as those changes could break user charts
const GLOBAL_CONTEXT = {
console,
_,
colors,
};

// Copied/modified from https://github.com/hacksparrow/safe-eval/blob/master/index.js
Expand Down
5 changes: 3 additions & 2 deletions superset/assets/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion superset/assets/visualizations/deckgl/factory.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
33 changes: 33 additions & 0 deletions superset/assets/visualizations/deckgl/layers/common.js
Original file line number Diff line number Diff line change
@@ -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),
};
}
Loading

0 comments on commit 59ea9f3

Please sign in to comment.