From 6959b70c1c2801b0c828b332fa79f562495fcd2f Mon Sep 17 00:00:00 2001 From: Beto Dealmeida Date: Tue, 21 Aug 2018 21:31:03 -0700 Subject: [PATCH] Add categories and time slider to arc deck.gl viz (#5638) * Fix legend position * Add categories and play slider to arc viz * New functionality to arc viz --- superset/assets/src/explore/visTypes.jsx | 13 +- superset/assets/src/visualizations/Legend.jsx | 1 + .../deckgl/CategoricalDeckGLContainer.jsx | 158 ++++++++++++++++ .../src/visualizations/deckgl/layers/arc.jsx | 38 ++-- .../visualizations/deckgl/layers/scatter.jsx | 171 +----------------- superset/viz.py | 10 + 6 files changed, 204 insertions(+), 187 deletions(-) create mode 100644 superset/assets/src/visualizations/deckgl/CategoricalDeckGLContainer.jsx diff --git a/superset/assets/src/explore/visTypes.jsx b/superset/assets/src/explore/visTypes.jsx index 2686b2d345..5df65dfd9e 100644 --- a/superset/assets/src/explore/visTypes.jsx +++ b/superset/assets/src/explore/visTypes.jsx @@ -759,7 +759,8 @@ export const visTypes = { { label: t('Arc'), controlSetRows: [ - ['color_picker', null], + ['color_picker', 'legend_position'], + ['dimension', 'color_scheme'], ['stroke_width', null], ], }, @@ -773,6 +774,16 @@ export const visTypes = { ], }, ], + controlOverrides: { + dimension: { + label: t('Categorical Color'), + description: t('Pick a dimension from which categorical colors are defined'), + }, + size: { + validators: [], + }, + time_grain_sqla: timeGrainSqlaAnimationOverrides, + }, }, deck_scatter: { diff --git a/superset/assets/src/visualizations/Legend.jsx b/superset/assets/src/visualizations/Legend.jsx index 7de070eab0..57bd430dc9 100644 --- a/superset/assets/src/visualizations/Legend.jsx +++ b/superset/assets/src/visualizations/Legend.jsx @@ -42,6 +42,7 @@ export default class Legend extends React.PureComponent { const vertical = this.props.position.charAt(0) === 't' ? 'top' : 'bottom'; const horizontal = this.props.position.charAt(1) === 'r' ? 'right' : 'left'; const style = { + position: 'absolute', [vertical]: '0px', [horizontal]: '10px', }; diff --git a/superset/assets/src/visualizations/deckgl/CategoricalDeckGLContainer.jsx b/superset/assets/src/visualizations/deckgl/CategoricalDeckGLContainer.jsx new file mode 100644 index 0000000000..39a202519b --- /dev/null +++ b/superset/assets/src/visualizations/deckgl/CategoricalDeckGLContainer.jsx @@ -0,0 +1,158 @@ +/* eslint no-underscore-dangle: ["error", { "allow": ["", "__timestamp"] }] */ + +import React from 'react'; +import PropTypes from 'prop-types'; + +import AnimatableDeckGLContainer from './AnimatableDeckGLContainer'; +import Legend from '../Legend'; + +import { getColorFromScheme, hexToRGB } from '../../modules/colors'; +import { getPlaySliderParams } from '../../modules/time'; +import sandboxedEval from '../../modules/sandbox'; + +function getCategories(fd, data) { + 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 categories = {}; + data.forEach((d) => { + if (d.cat_color != null && !categories.hasOwnProperty(d.cat_color)) { + let color; + if (fd.dimension) { + color = hexToRGB(getColorFromScheme(d.cat_color, fd.color_scheme), c.a * 255); + } else { + color = fixedColor; + } + categories[d.cat_color] = { color, enabled: true }; + } + }); + return categories; +} + +const propTypes = { + slice: PropTypes.object.isRequired, + data: PropTypes.array.isRequired, + mapboxApiKey: PropTypes.string.isRequired, + setControlValue: PropTypes.func.isRequired, + viewport: PropTypes.object.isRequired, + getLayer: PropTypes.func.isRequired, +}; + +export default class CategoricalDeckGLContainer extends React.PureComponent { + /* + * A Deck.gl container that handles categories. + * + * The container will have an interactive legend, populated from the + * categories present in the data. + */ + + /* eslint-disable-next-line react/sort-comp */ + static getDerivedStateFromProps(nextProps) { + const fd = nextProps.slice.formData; + + const timeGrain = fd.time_grain_sqla || fd.granularity || 'PT1M'; + const timestamps = nextProps.data.map(f => f.__timestamp); + const { start, end, step, values, disabled } = getPlaySliderParams(timestamps, timeGrain); + const categories = getCategories(fd, nextProps.data); + + return { start, end, step, values, disabled, categories }; + } + constructor(props) { + super(props); + this.state = CategoricalDeckGLContainer.getDerivedStateFromProps(props); + + this.getLayers = this.getLayers.bind(this); + this.toggleCategory = this.toggleCategory.bind(this); + this.showSingleCategory = this.showSingleCategory.bind(this); + } + componentWillReceiveProps(nextProps) { + this.setState(CategoricalDeckGLContainer.getDerivedStateFromProps(nextProps, this.state)); + } + addColor(data, fd) { + const c = fd.color_picker || { r: 0, g: 0, b: 0, a: 1 }; + const fixedColor = [c.r, c.g, c.b, 255 * c.a]; + + return data.map((d) => { + let color; + if (fd.dimension) { + color = hexToRGB(getColorFromScheme(d.cat_color, fd.color_scheme), c.a * 255); + } else { + color = fixedColor; + } + return { ...d, color }; + }); + } + getLayers(values) { + const fd = this.props.slice.formData; + let data = [...this.props.data]; + + // Add colors from categories or fixed color + data = this.addColor(data, fd); + + // Apply user defined data mutator if defined + if (fd.js_data_mutator) { + const jsFnMutator = sandboxedEval(fd.js_data_mutator); + data = jsFnMutator(data); + } + + // Filter by time + if (values[0] === values[1] || values[1] === this.end) { + data = data.filter(d => d.__timestamp >= values[0] && d.__timestamp <= values[1]); + } else { + data = data.filter(d => d.__timestamp >= values[0] && d.__timestamp < values[1]); + } + + // Show only categories selected in the legend + if (fd.dimension) { + data = data.filter(d => this.state.categories[d.cat_color].enabled); + } + + return [this.props.getLayer(fd, data, this.props.slice)]; + } + toggleCategory(category) { + const categoryState = this.state.categories[category]; + categoryState.enabled = !categoryState.enabled; + const categories = { ...this.state.categories, [category]: categoryState }; + + // if all categories are disabled, enable all -- similar to nvd3 + if (Object.values(categories).every(v => !v.enabled)) { + /* eslint-disable no-param-reassign */ + Object.values(categories).forEach((v) => { v.enabled = true; }); + } + + this.setState({ categories }); + } + showSingleCategory(category) { + const categories = { ...this.state.categories }; + /* eslint-disable no-param-reassign */ + Object.values(categories).forEach((v) => { v.enabled = false; }); + categories[category].enabled = true; + this.setState({ categories }); + } + render() { + return ( +
+ + + +
+ ); + } +} + +CategoricalDeckGLContainer.propTypes = propTypes; diff --git a/superset/assets/src/visualizations/deckgl/layers/arc.jsx b/superset/assets/src/visualizations/deckgl/layers/arc.jsx index d34e7a13f6..b17e357326 100644 --- a/superset/assets/src/visualizations/deckgl/layers/arc.jsx +++ b/superset/assets/src/visualizations/deckgl/layers/arc.jsx @@ -1,12 +1,13 @@ +/* eslint no-underscore-dangle: ["error", { "allow": ["", "__timestamp"] }] */ + import React from 'react'; import ReactDOM from 'react-dom'; import { ArcLayer } from 'deck.gl'; -import DeckGLContainer from './../DeckGLContainer'; +import CategoricalDeckGLContainer from '../CategoricalDeckGLContainer'; import * as common from './common'; -import sandboxedEval from '../../../modules/sandbox'; function getPoints(data) { const points = []; @@ -17,20 +18,7 @@ function getPoints(data) { return points; } -function getLayer(formData, payload, slice) { - const fd = formData; - const fc = fd.color_picker; - let data = payload.data.arcs.map(d => ({ - ...d, - color: [fc.r, fc.g, fc.b, 255 * fc.a], - })); - - if (fd.js_data_mutator) { - // Applying user defined data mutator if defined - const jsFnMutator = sandboxedEval(fd.js_data_mutator); - data = jsFnMutator(data); - } - +function getLayer(fd, data, slice) { return new ArcLayer({ id: `path-layer-${fd.slice_id}`, data, @@ -40,23 +28,25 @@ function getLayer(formData, payload, slice) { } function deckArc(slice, payload, setControlValue) { - const layer = getLayer(slice.formData, payload, slice); + const fd = slice.formData; let viewport = { - ...slice.formData.viewport, + ...fd.viewport, width: slice.width(), height: slice.height(), }; - if (slice.formData.autozoom) { + if (fd.autozoom) { viewport = common.fitViewport(viewport, getPoints(payload.data.arcs)); } + ReactDOM.render( - , document.getElementById(slice.containerId), ); diff --git a/superset/assets/src/visualizations/deckgl/layers/scatter.jsx b/superset/assets/src/visualizations/deckgl/layers/scatter.jsx index 768978718e..07590551ac 100644 --- a/superset/assets/src/visualizations/deckgl/layers/scatter.jsx +++ b/superset/assets/src/visualizations/deckgl/layers/scatter.jsx @@ -2,82 +2,30 @@ import React from 'react'; import ReactDOM from 'react-dom'; -import PropTypes from 'prop-types'; import { ScatterplotLayer } from 'deck.gl'; -import AnimatableDeckGLContainer from '../AnimatableDeckGLContainer'; -import Legend from '../../Legend'; - +import CategoricalDeckGLContainer from '../CategoricalDeckGLContainer'; import * as common from './common'; -import { getColorFromScheme, hexToRGB } from '../../../modules/colors'; -import { getPlaySliderParams } from '../../../modules/time'; import { unitToRadius } from '../../../modules/geo'; -import sandboxedEval from '../../../modules/sandbox'; function getPoints(data) { return data.map(d => d.position); } -function getCategories(formData, payload) { - 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 categories = {}; - - payload.data.features.forEach((d) => { - if (d.cat_color != null && !categories.hasOwnProperty(d.cat_color)) { - let color; - if (fd.dimension) { - color = hexToRGB(getColorFromScheme(d.cat_color, fd.color_scheme), c.a * 255); - } else { - color = fixedColor; - } - categories[d.cat_color] = { color, enabled: true }; - } - }); - return categories; -} - -function getLayer(formData, payload, slice, filters) { - 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]; - - let data = payload.data.features.map((d) => { +function getLayer(fd, data, slice) { + const dataWithRadius = data.map((d) => { let radius = unitToRadius(fd.point_unit, d.radius) || 10; if (fd.multiplier) { radius *= fd.multiplier; } - let color; - if (fd.dimension) { - color = hexToRGB(getColorFromScheme(d.cat_color, fd.color_scheme), c.a * 255); - } else { - color = fixedColor; - } - return { - ...d, - radius, - color, - }; + return { ...d, radius }; }); - if (fd.js_data_mutator) { - // Applying user defined data mutator if defined - const jsFnMutator = sandboxedEval(fd.js_data_mutator); - data = jsFnMutator(data); - } - - if (filters != null) { - filters.forEach((f) => { - data = data.filter(f); - }); - } - return new ScatterplotLayer({ id: `scatter-layer-${fd.slice_id}`, - data, + data: dataWithRadius, fp64: true, radiusMinPixels: fd.min_radius || null, radiusMaxPixels: fd.max_radius || null, @@ -86,109 +34,6 @@ function getLayer(formData, payload, slice, filters) { }); } -const propTypes = { - slice: PropTypes.object.isRequired, - payload: PropTypes.object.isRequired, - setControlValue: PropTypes.func.isRequired, - viewport: PropTypes.object.isRequired, -}; - -class DeckGLScatter extends React.PureComponent { - /* eslint-disable-next-line react/sort-comp */ - static getDerivedStateFromProps(nextProps) { - const fd = nextProps.slice.formData; - - const timeGrain = fd.time_grain_sqla || fd.granularity || 'PT1M'; - const timestamps = nextProps.payload.data.features.map(f => f.__timestamp); - const { start, end, step, values, disabled } = getPlaySliderParams(timestamps, timeGrain); - - const categories = getCategories(fd, nextProps.payload); - - return { start, end, step, values, disabled, categories }; - } - constructor(props) { - super(props); - this.state = DeckGLScatter.getDerivedStateFromProps(props); - - this.getLayers = this.getLayers.bind(this); - this.toggleCategory = this.toggleCategory.bind(this); - this.showSingleCategory = this.showSingleCategory.bind(this); - } - componentWillReceiveProps(nextProps) { - this.setState(DeckGLScatter.getDerivedStateFromProps(nextProps, this.state)); - } - getLayers(values) { - const filters = []; - - // time filter - if (values[0] === values[1] || values[1] === this.end) { - filters.push(d => d.__timestamp >= values[0] && d.__timestamp <= values[1]); - } else { - filters.push(d => d.__timestamp >= values[0] && d.__timestamp < values[1]); - } - - // legend filter - if (this.props.slice.formData.dimension) { - filters.push(d => this.state.categories[d.cat_color].enabled); - } - - const layer = getLayer( - this.props.slice.formData, - this.props.payload, - this.props.slice, - filters); - - return [layer]; - } - toggleCategory(category) { - const categoryState = this.state.categories[category]; - categoryState.enabled = !categoryState.enabled; - const categories = { ...this.state.categories, [category]: categoryState }; - - // if all categories are disabled, enable all -- similar to nvd3 - if (Object.values(categories).every(v => !v.enabled)) { - /* eslint-disable no-param-reassign */ - Object.values(categories).forEach((v) => { v.enabled = true; }); - } - - this.setState({ categories }); - } - showSingleCategory(category) { - const categories = { ...this.state.categories }; - /* eslint-disable no-param-reassign */ - Object.values(categories).forEach((v) => { v.enabled = false; }); - categories[category].enabled = true; - this.setState({ categories }); - } - render() { - return ( -
- - - -
- ); - } -} - -DeckGLScatter.propTypes = propTypes; - function deckScatter(slice, payload, setControlValue) { const fd = slice.formData; let viewport = { @@ -202,11 +47,13 @@ function deckScatter(slice, payload, setControlValue) { } ReactDOM.render( - , document.getElementById(slice.containerId), ); diff --git a/superset/viz.py b/superset/viz.py index 9113b038d0..14627488ff 100644 --- a/superset/viz.py +++ b/superset/viz.py @@ -2381,11 +2381,21 @@ class DeckArc(BaseDeckGLViz): viz_type = 'deck_arc' verbose_name = _('Deck.gl - Arc') spatial_control_keys = ['start_spatial', 'end_spatial'] + is_timeseries = True + + def query_obj(self): + fd = self.form_data + self.is_timeseries = bool( + fd.get('time_grain_sqla') or fd.get('granularity')) + return super(DeckArc, self).query_obj() def get_properties(self, d): + dim = self.form_data.get('dimension') return { 'sourcePosition': d.get('start_spatial'), 'targetPosition': d.get('end_spatial'), + 'cat_color': d.get(dim) if dim else None, + DTTM_ALIAS: d.get(DTTM_ALIAS), } def get_data(self, df):