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
This commit is contained in:
parent
86a03d1dc8
commit
7089344623
|
|
@ -179,6 +179,20 @@ export const controls = {
|
||||||
default: colorPrimary,
|
default: colorPrimary,
|
||||||
renderTrigger: true,
|
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: {
|
fill_color_picker: {
|
||||||
label: t('Fill Color'),
|
label: t('Fill Color'),
|
||||||
|
|
|
||||||
|
|
@ -679,7 +679,7 @@ export const visTypes = {
|
||||||
{
|
{
|
||||||
label: t('Point Color'),
|
label: t('Point Color'),
|
||||||
controlSetRows: [
|
controlSetRows: [
|
||||||
['color_picker', null],
|
['color_picker', 'legend_position'],
|
||||||
['dimension', 'color_scheme'],
|
['dimension', 'color_scheme'],
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,9 @@
|
||||||
"mousetrap": "^1.6.1",
|
"mousetrap": "^1.6.1",
|
||||||
"mustache": "^2.2.1",
|
"mustache": "^2.2.1",
|
||||||
"nvd3": "1.8.6",
|
"nvd3": "1.8.6",
|
||||||
|
"object.entries": "^1.0.4",
|
||||||
|
"object.keys": "^0.1.0",
|
||||||
|
"object.values": "^1.0.4",
|
||||||
"po2json": "^0.4.5",
|
"po2json": "^0.4.5",
|
||||||
"prop-types": "^15.6.0",
|
"prop-types": "^15.6.0",
|
||||||
"react": "^15.6.2",
|
"react": "^15.6.2",
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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 (
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
onClick={() => this.props.toggleCategory(k)}
|
||||||
|
onDoubleClick={() => this.props.showSingleCategory(k)}
|
||||||
|
>
|
||||||
|
<span style={style}>{icon}</span> {k}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className={'legend'} style={style}>
|
||||||
|
<ul className={'categories'}>{categories}</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Legend.propTypes = propTypes;
|
||||||
|
Legend.defaultProps = defaultProps;
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
.play-slider {
|
.play-slider {
|
||||||
height: 100px;
|
position: absolute;
|
||||||
margin-top: -5px;
|
bottom: -16px;
|
||||||
|
height: 20px;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.slider-selection {
|
.slider-selection {
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ const propTypes = {
|
||||||
values: PropTypes.array.isRequired,
|
values: PropTypes.array.isRequired,
|
||||||
disabled: PropTypes.bool,
|
disabled: PropTypes.bool,
|
||||||
viewport: PropTypes.object.isRequired,
|
viewport: PropTypes.object.isRequired,
|
||||||
|
children: PropTypes.node,
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultProps = {
|
const defaultProps = {
|
||||||
|
|
@ -48,6 +49,7 @@ export default class AnimatableDeckGLContainer extends React.Component {
|
||||||
onChange={newValues => this.setState({ values: newValues })}
|
onChange={newValues => this.setState({ values: newValues })}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
{this.props.children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import PropTypes from 'prop-types';
|
||||||
import { ScatterplotLayer } from 'deck.gl';
|
import { ScatterplotLayer } from 'deck.gl';
|
||||||
|
|
||||||
import AnimatableDeckGLContainer from '../AnimatableDeckGLContainer';
|
import AnimatableDeckGLContainer from '../AnimatableDeckGLContainer';
|
||||||
|
import Legend from '../../Legend';
|
||||||
|
|
||||||
import * as common from './common';
|
import * as common from './common';
|
||||||
import { getColorFromScheme, hexToRGB } from '../../../javascripts/modules/colors';
|
import { getColorFromScheme, hexToRGB } from '../../../javascripts/modules/colors';
|
||||||
|
|
@ -39,7 +40,27 @@ function getPoints(data) {
|
||||||
return data.map(d => d.position);
|
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 fd = formData;
|
||||||
const c = fd.color_picker || { r: 0, g: 0, b: 0, a: 1 };
|
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 fixedColor = [c.r, c.g, c.b, 255 * c.a];
|
||||||
|
|
@ -68,8 +89,10 @@ function getLayer(formData, payload, slice, inFrame) {
|
||||||
data = jsFnMutator(data);
|
data = jsFnMutator(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (inFrame != null) {
|
if (filters != null) {
|
||||||
data = data.filter(inFrame);
|
filters.forEach((f) => {
|
||||||
|
data = data.filter(f);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return new ScatterplotLayer({
|
return new ScatterplotLayer({
|
||||||
|
|
@ -109,46 +132,87 @@ class DeckGLScatter extends React.PureComponent {
|
||||||
const values = timeGrain != null ? [start, start + step] : [start, end];
|
const values = timeGrain != null ? [start, start + step] : [start, end];
|
||||||
const disabled = timestamps.every(timestamp => timestamp === null);
|
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) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
this.state = DeckGLScatter.getDerivedStateFromProps(props);
|
this.state = DeckGLScatter.getDerivedStateFromProps(props);
|
||||||
|
|
||||||
this.getLayers = this.getLayers.bind(this);
|
this.getLayers = this.getLayers.bind(this);
|
||||||
|
this.toggleCategory = this.toggleCategory.bind(this);
|
||||||
|
this.showSingleCategory = this.showSingleCategory.bind(this);
|
||||||
}
|
}
|
||||||
componentWillReceiveProps(nextProps) {
|
componentWillReceiveProps(nextProps) {
|
||||||
this.setState(DeckGLScatter.getDerivedStateFromProps(nextProps, this.state));
|
this.setState(DeckGLScatter.getDerivedStateFromProps(nextProps, this.state));
|
||||||
}
|
}
|
||||||
getLayers(values) {
|
getLayers(values) {
|
||||||
let inFrame;
|
const filters = [];
|
||||||
|
|
||||||
|
// time filter
|
||||||
if (values[0] === values[1] || values[1] === this.end) {
|
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 {
|
} 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(
|
const layer = getLayer(
|
||||||
this.props.slice.formData,
|
this.props.slice.formData,
|
||||||
this.props.payload,
|
this.props.payload,
|
||||||
this.props.slice,
|
this.props.slice,
|
||||||
inFrame);
|
filters);
|
||||||
|
|
||||||
return [layer];
|
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() {
|
render() {
|
||||||
return (
|
return (
|
||||||
<AnimatableDeckGLContainer
|
<div>
|
||||||
getLayers={this.getLayers}
|
<AnimatableDeckGLContainer
|
||||||
start={this.state.start}
|
getLayers={this.getLayers}
|
||||||
end={this.state.end}
|
start={this.state.start}
|
||||||
step={this.state.step}
|
end={this.state.end}
|
||||||
values={this.state.values}
|
step={this.state.step}
|
||||||
disabled={this.state.disabled}
|
values={this.state.values}
|
||||||
viewport={this.props.viewport}
|
disabled={this.state.disabled}
|
||||||
mapboxApiAccessToken={this.props.payload.data.mapboxApiKey}
|
viewport={this.props.viewport}
|
||||||
mapStyle={this.props.slice.formData.mapbox_style}
|
mapboxApiAccessToken={this.props.payload.data.mapboxApiKey}
|
||||||
setControlValue={this.props.setControlValue}
|
mapStyle={this.props.slice.formData.mapbox_style}
|
||||||
/>
|
setControlValue={this.props.setControlValue}
|
||||||
|
>
|
||||||
|
<Legend
|
||||||
|
categories={this.state.categories}
|
||||||
|
toggleCategory={this.toggleCategory}
|
||||||
|
showSingleCategory={this.showSingleCategory}
|
||||||
|
position={this.props.slice.formData.legend_position}
|
||||||
|
/>
|
||||||
|
</AnimatableDeckGLContainer>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue