From 70893446238a343aa2a1bcfe0c207832100ea3a6 Mon Sep 17 00:00:00 2001 From: Beto Dealmeida Date: Wed, 14 Mar 2018 16:40:14 -0700 Subject: [PATCH] Legend for deck.gl scatterplot (#4572) * Initial work * Working version * Specify legend position * Max height with scroll * Fix lint * Better compatibility with nvd3 * Fix object.keys polyfill version * Fix lint --- .../javascripts/explore/stores/controls.jsx | 14 +++ .../javascripts/explore/stores/visTypes.js | 2 +- superset/assets/package.json | 3 + superset/assets/visualizations/Legend.css | 22 ++++ superset/assets/visualizations/Legend.jsx | 58 ++++++++++ superset/assets/visualizations/PlaySlider.css | 6 +- .../deckgl/AnimatableDeckGLContainer.jsx | 2 + .../visualizations/deckgl/layers/scatter.jsx | 104 ++++++++++++++---- 8 files changed, 188 insertions(+), 23 deletions(-) create mode 100644 superset/assets/visualizations/Legend.css create mode 100644 superset/assets/visualizations/Legend.jsx diff --git a/superset/assets/javascripts/explore/stores/controls.jsx b/superset/assets/javascripts/explore/stores/controls.jsx index e9d73013d8d81..800e1dfde5e64 100644 --- a/superset/assets/javascripts/explore/stores/controls.jsx +++ b/superset/assets/javascripts/explore/stores/controls.jsx @@ -179,6 +179,20 @@ export const controls = { default: colorPrimary, renderTrigger: true, }, + legend_position: { + label: t('Legend Position'), + description: t('Choose the position of the legend'), + type: 'SelectControl', + clearable: false, + default: 'Top right', + choices: [ + ['tl', 'Top left'], + ['tr', 'Top right'], + ['bl', 'Bottom left'], + ['br', 'Bottom right'], + ], + renderTrigger: true, + }, fill_color_picker: { label: t('Fill Color'), diff --git a/superset/assets/javascripts/explore/stores/visTypes.js b/superset/assets/javascripts/explore/stores/visTypes.js index 836da7b9cd690..dd144c55c4769 100644 --- a/superset/assets/javascripts/explore/stores/visTypes.js +++ b/superset/assets/javascripts/explore/stores/visTypes.js @@ -679,7 +679,7 @@ export const visTypes = { { label: t('Point Color'), controlSetRows: [ - ['color_picker', null], + ['color_picker', 'legend_position'], ['dimension', 'color_scheme'], ], }, diff --git a/superset/assets/package.json b/superset/assets/package.json index 1c5089572ae50..7d41be37283b3 100644 --- a/superset/assets/package.json +++ b/superset/assets/package.json @@ -75,6 +75,9 @@ "mousetrap": "^1.6.1", "mustache": "^2.2.1", "nvd3": "1.8.6", + "object.entries": "^1.0.4", + "object.keys": "^0.1.0", + "object.values": "^1.0.4", "po2json": "^0.4.5", "prop-types": "^15.6.0", "react": "^15.6.2", diff --git a/superset/assets/visualizations/Legend.css b/superset/assets/visualizations/Legend.css new file mode 100644 index 0000000000000..4c6222d79df6a --- /dev/null +++ b/superset/assets/visualizations/Legend.css @@ -0,0 +1,22 @@ +div.legend { + font-size: 90%; + position: absolute; + background: #fff; + box-shadow: 0 0 4px rgba(0, 0, 0, 0.15); + margin: 24px; + padding: 12px 24px; + outline: none; + overflow-y: scroll; + max-height: 200px; +} + +ul.categories { + list-style: none; + padding-left: 0; + margin: 0; +} + +ul.categories li a { + color: rgb(51, 51, 51); + text-decoration: none; +} diff --git a/superset/assets/visualizations/Legend.jsx b/superset/assets/visualizations/Legend.jsx new file mode 100644 index 0000000000000..7de070eab0069 --- /dev/null +++ b/superset/assets/visualizations/Legend.jsx @@ -0,0 +1,58 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import './Legend.css'; + +const propTypes = { + categories: PropTypes.object, + toggleCategory: PropTypes.func, + showSingleCategory: PropTypes.func, + position: PropTypes.oneOf(['tl', 'tr', 'bl', 'br']), +}; + +const defaultProps = { + categories: {}, + toggleCategory: () => {}, + showSingleCategory: () => {}, + position: 'tr', +}; + +export default class Legend extends React.PureComponent { + render() { + if (Object.keys(this.props.categories).length === 0) { + return null; + } + + const categories = Object.entries(this.props.categories).map(([k, v]) => { + const style = { color: 'rgba(' + v.color.join(', ') + ')' }; + const icon = v.enabled ? '\u25CF' : '\u25CB'; + return ( +
  • + this.props.toggleCategory(k)} + onDoubleClick={() => this.props.showSingleCategory(k)} + > + {icon} {k} + +
  • + ); + }); + + const vertical = this.props.position.charAt(0) === 't' ? 'top' : 'bottom'; + const horizontal = this.props.position.charAt(1) === 'r' ? 'right' : 'left'; + const style = { + [vertical]: '0px', + [horizontal]: '10px', + }; + + return ( +
    + +
    + ); + } +} + +Legend.propTypes = propTypes; +Legend.defaultProps = defaultProps; diff --git a/superset/assets/visualizations/PlaySlider.css b/superset/assets/visualizations/PlaySlider.css index e4338d9634bf5..7de07de54ab1c 100644 --- a/superset/assets/visualizations/PlaySlider.css +++ b/superset/assets/visualizations/PlaySlider.css @@ -1,6 +1,8 @@ .play-slider { - height: 100px; - margin-top: -5px; + position: absolute; + bottom: -16px; + height: 20px; + width: 100%; } .slider-selection { diff --git a/superset/assets/visualizations/deckgl/AnimatableDeckGLContainer.jsx b/superset/assets/visualizations/deckgl/AnimatableDeckGLContainer.jsx index 0343421bdee27..3bee4a4d400e3 100644 --- a/superset/assets/visualizations/deckgl/AnimatableDeckGLContainer.jsx +++ b/superset/assets/visualizations/deckgl/AnimatableDeckGLContainer.jsx @@ -12,6 +12,7 @@ const propTypes = { values: PropTypes.array.isRequired, disabled: PropTypes.bool, viewport: PropTypes.object.isRequired, + children: PropTypes.node, }; const defaultProps = { @@ -48,6 +49,7 @@ export default class AnimatableDeckGLContainer extends React.Component { onChange={newValues => this.setState({ values: newValues })} /> } + {this.props.children} ); } diff --git a/superset/assets/visualizations/deckgl/layers/scatter.jsx b/superset/assets/visualizations/deckgl/layers/scatter.jsx index 26d320c502713..3d8e99cb76b7a 100644 --- a/superset/assets/visualizations/deckgl/layers/scatter.jsx +++ b/superset/assets/visualizations/deckgl/layers/scatter.jsx @@ -7,6 +7,7 @@ import PropTypes from 'prop-types'; import { ScatterplotLayer } from 'deck.gl'; import AnimatableDeckGLContainer from '../AnimatableDeckGLContainer'; +import Legend from '../../Legend'; import * as common from './common'; import { getColorFromScheme, hexToRGB } from '../../../javascripts/modules/colors'; @@ -39,7 +40,27 @@ function getPoints(data) { return data.map(d => d.position); } -function getLayer(formData, payload, slice, inFrame) { +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]; @@ -68,8 +89,10 @@ function getLayer(formData, payload, slice, inFrame) { data = jsFnMutator(data); } - if (inFrame != null) { - data = data.filter(inFrame); + if (filters != null) { + filters.forEach((f) => { + data = data.filter(f); + }); } return new ScatterplotLayer({ @@ -109,46 +132,87 @@ class DeckGLScatter extends React.PureComponent { const values = timeGrain != null ? [start, start + step] : [start, end]; const disabled = timestamps.every(timestamp => timestamp === null); - return { start, end, step, values, disabled }; + 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) { - let inFrame; + const filters = []; + + // time filter if (values[0] === values[1] || values[1] === this.end) { - inFrame = t => t.__timestamp >= values[0] && t.__timestamp <= values[1]; + filters.push(d => d.__timestamp >= values[0] && d.__timestamp <= values[1]); } else { - inFrame = t => t.__timestamp >= values[0] && t.__timestamp < values[1]; + 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, - inFrame); + 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 ( - +
    + + + +
    ); } }