mirror of
https://github.com/apache/superset.git
synced 2026-04-22 17:45:21 +00:00
Improvements to the polygon spatial viz (#6178)
* WIP * WIP * WIP * WIP * Fix color bucketing * Fixed colors * Fix no num categories selected * Colors working * Fix no metric selected * Visual cues for selection * Add unit tests * Remove jest from deps * Rename category to bucket * Small fixes * Fix lint * Fix unit tests * Remove duplicate hexToRGB * Fix import * Change order of arguments in getBuckets * Refactor function signature
This commit is contained in:
@@ -21,7 +21,7 @@ export function getLayer(fd, payload, onAddFilter, setTooltip) {
|
||||
getSourceColor: d => d.sourceColor || d.color || [sc.r, sc.g, sc.b, 255 * sc.a],
|
||||
getTargetColor: d => d.targetColor || d.color || [tc.r, tc.g, tc.b, 255 * tc.a],
|
||||
strokeWidth: (fd.stroke_width) ? fd.stroke_width : 3,
|
||||
...commonLayerProps(fd, onAddFilter, setTooltip),
|
||||
...commonLayerProps(fd, setTooltip),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -88,7 +88,7 @@ export function getLayer(formData, payload, onAddFilter, setTooltip) {
|
||||
stroked: fd.stroked,
|
||||
extruded: fd.extruded,
|
||||
pointRadiusScale: fd.point_radius_scale,
|
||||
...commonLayerProps(fd, onAddFilter, setTooltip),
|
||||
...commonLayerProps(fd, setTooltip),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ export function getLayer(formData, payload, onAddFilter, setTooltip) {
|
||||
outline: false,
|
||||
getElevationValue: points => points.reduce((sum, point) => sum + point.weight, 0),
|
||||
getColorValue: points => points.reduce((sum, point) => sum + point.weight, 0),
|
||||
...commonLayerProps(fd, onAddFilter, setTooltip),
|
||||
...commonLayerProps(fd, setTooltip),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ export function getLayer(formData, payload, onAddFilter, setTooltip) {
|
||||
outline: false,
|
||||
getElevationValue: points => points.reduce((sum, point) => sum + point.weight, 0),
|
||||
getColorValue: points => points.reduce((sum, point) => sum + point.weight, 0),
|
||||
...commonLayerProps(fd, onAddFilter, setTooltip),
|
||||
...commonLayerProps(fd, setTooltip),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ export function getLayer(formData, payload, onAddFilter, setTooltip) {
|
||||
data,
|
||||
rounded: true,
|
||||
widthScale: 1,
|
||||
...commonLayerProps(fd, onAddFilter, setTooltip),
|
||||
...commonLayerProps(fd, setTooltip),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,31 +1,41 @@
|
||||
import d3 from 'd3';
|
||||
import { PolygonLayer } from 'deck.gl';
|
||||
import { flatten } from 'lodash';
|
||||
import { colorScalerFactory } from '../../../../modules/colors';
|
||||
import { commonLayerProps } from '../common';
|
||||
import sandboxedEval from '../../../../modules/sandbox';
|
||||
import { createDeckGLComponent } from '../../factory';
|
||||
/* eslint no-underscore-dangle: ["error", { "allow": ["", "__timestamp"] }] */
|
||||
|
||||
function getPoints(features) {
|
||||
return flatten(features.map(d => d.polygon), true);
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { PolygonLayer } from 'deck.gl';
|
||||
|
||||
import AnimatableDeckGLContainer from '../../AnimatableDeckGLContainer';
|
||||
import Legend from '../../../Legend';
|
||||
import { getBuckets, getBreakPointColorScaler } from '../../utils';
|
||||
|
||||
import { commonLayerProps } from '../common';
|
||||
import { getPlaySliderParams } from '../../../../modules/time';
|
||||
import sandboxedEval from '../../../../modules/sandbox';
|
||||
|
||||
const DOUBLE_CLICK_TRESHOLD = 250; // milliseconds
|
||||
|
||||
function getElevation(d, colorScaler) {
|
||||
/* in deck.gl 5.3.4 (used in Superset as of 2018-10-24), if a polygon has
|
||||
* opacity zero it will make everything behind it have opacity zero,
|
||||
* effectively showing the map layer no matter what other polygons are
|
||||
* behind it.
|
||||
*/
|
||||
return colorScaler(d)[3] === 0
|
||||
? 0
|
||||
: d.elevation;
|
||||
}
|
||||
|
||||
export function getLayer(formData, payload, onAddFilter, setTooltip) {
|
||||
export function getLayer(formData, payload, setTooltip, selected, onSelect, filters) {
|
||||
const fd = formData;
|
||||
const fc = fd.fill_color_picker;
|
||||
const sc = fd.stroke_color_picker;
|
||||
let data = [...payload.data.features];
|
||||
const mainMetric = payload.data.metricLabels.length ? payload.data.metricLabels[0] : null;
|
||||
|
||||
let colorScaler;
|
||||
if (mainMetric) {
|
||||
const ext = d3.extent(data, d => d[mainMetric]);
|
||||
const scaler = colorScalerFactory(fd.linear_color_scheme, null, null, ext, true);
|
||||
colorScaler = (d) => {
|
||||
const c = scaler(d[mainMetric]);
|
||||
c[3] = (fd.opacity / 100.0) * 255;
|
||||
return c;
|
||||
};
|
||||
if (filters != null) {
|
||||
filters.forEach((f) => {
|
||||
data = data.filter(f);
|
||||
});
|
||||
}
|
||||
|
||||
if (fd.js_data_mutator) {
|
||||
@@ -34,18 +44,169 @@ export function getLayer(formData, payload, onAddFilter, setTooltip) {
|
||||
data = jsFnMutator(data);
|
||||
}
|
||||
|
||||
// base color for the polygons
|
||||
const baseColorScaler = fd.metric === null
|
||||
? () => [fc.r, fc.g, fc.b, 255 * fc.a]
|
||||
: getBreakPointColorScaler(fd, data);
|
||||
|
||||
// when polygons are selected, reduce the opacity of non-selected polygons
|
||||
const colorScaler = (d) => {
|
||||
const baseColor = baseColorScaler(d);
|
||||
if (selected.length > 0 && selected.indexOf(d[fd.line_column]) === -1) {
|
||||
baseColor[3] /= 2;
|
||||
}
|
||||
return baseColor;
|
||||
};
|
||||
|
||||
return new PolygonLayer({
|
||||
id: `path-layer-${fd.slice_id}`,
|
||||
data,
|
||||
pickable: true,
|
||||
filled: fd.filled,
|
||||
stroked: fd.stroked,
|
||||
getFillColor: colorScaler || [fc.r, fc.g, fc.b, 255 * fc.a],
|
||||
getPolygon: d => d.polygon,
|
||||
getFillColor: colorScaler,
|
||||
getLineColor: [sc.r, sc.g, sc.b, 255 * sc.a],
|
||||
getLineWidth: fd.line_width,
|
||||
extruded: fd.extruded,
|
||||
getElevation: d => getElevation(d, colorScaler),
|
||||
elevationScale: fd.multiplier,
|
||||
fp64: true,
|
||||
...commonLayerProps(fd, onAddFilter, setTooltip),
|
||||
...commonLayerProps(fd, setTooltip, onSelect),
|
||||
});
|
||||
}
|
||||
|
||||
export default createDeckGLComponent(getLayer, getPoints);
|
||||
const propTypes = {
|
||||
formData: PropTypes.object.isRequired,
|
||||
payload: PropTypes.object.isRequired,
|
||||
setControlValue: PropTypes.func.isRequired,
|
||||
viewport: PropTypes.object.isRequired,
|
||||
onAddFilter: PropTypes.func,
|
||||
setTooltip: PropTypes.func,
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
onAddFilter() {},
|
||||
setTooltip() {},
|
||||
};
|
||||
|
||||
class DeckGLPolygon extends React.PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
const fd = props.formData;
|
||||
const timeGrain = fd.time_grain_sqla || fd.granularity || 'PT1M';
|
||||
const timestamps = props.payload.data.features.map(f => f.__timestamp);
|
||||
const { start, end, getStep, values, disabled } = getPlaySliderParams(timestamps, timeGrain);
|
||||
this.state = {
|
||||
start,
|
||||
end,
|
||||
getStep,
|
||||
values,
|
||||
disabled,
|
||||
viewport: props.viewport,
|
||||
selected: [],
|
||||
lastClick: 0,
|
||||
};
|
||||
|
||||
this.getLayers = this.getLayers.bind(this);
|
||||
this.onSelect = this.onSelect.bind(this);
|
||||
this.onValuesChange = this.onValuesChange.bind(this);
|
||||
this.onViewportChange = this.onViewportChange.bind(this);
|
||||
}
|
||||
onSelect(polygon) {
|
||||
const { formData, onAddFilter } = this.props;
|
||||
|
||||
const now = new Date();
|
||||
const doubleClick = (now - this.state.lastClick) <= DOUBLE_CLICK_TRESHOLD;
|
||||
|
||||
// toggle selected polygons
|
||||
const selected = [...this.state.selected];
|
||||
if (doubleClick) {
|
||||
selected.splice(0, selected.length, polygon);
|
||||
} else if (formData.toggle_polygons) {
|
||||
const i = selected.indexOf(polygon);
|
||||
if (i === -1) {
|
||||
selected.push(polygon);
|
||||
} else {
|
||||
selected.splice(i, 1);
|
||||
}
|
||||
} else {
|
||||
selected.splice(0, 1, polygon);
|
||||
}
|
||||
|
||||
this.setState({ selected, lastClick: now });
|
||||
if (formData.table_filter) {
|
||||
onAddFilter(formData.line_column, selected, false, true);
|
||||
}
|
||||
}
|
||||
onValuesChange(values) {
|
||||
this.setState({
|
||||
values: Array.isArray(values)
|
||||
? values
|
||||
: [values, values + this.state.getStep(values)],
|
||||
});
|
||||
}
|
||||
onViewportChange(viewport) {
|
||||
this.setState({ viewport });
|
||||
}
|
||||
getLayers(values) {
|
||||
if (this.props.payload.data.features === undefined) {
|
||||
return [];
|
||||
}
|
||||
|
||||
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]);
|
||||
}
|
||||
|
||||
const layer = getLayer(
|
||||
this.props.formData,
|
||||
this.props.payload,
|
||||
this.props.setTooltip,
|
||||
this.state.selected,
|
||||
this.onSelect,
|
||||
filters);
|
||||
|
||||
return [layer];
|
||||
}
|
||||
render() {
|
||||
const { payload, formData, setControlValue } = this.props;
|
||||
const { start, end, getStep, values, disabled, viewport } = this.state;
|
||||
const buckets = getBuckets(formData, payload.data.features);
|
||||
return (
|
||||
<div style={{ position: 'relative' }}>
|
||||
<AnimatableDeckGLContainer
|
||||
getLayers={this.getLayers}
|
||||
start={start}
|
||||
end={end}
|
||||
getStep={getStep}
|
||||
values={values}
|
||||
onValuesChange={this.onValuesChange}
|
||||
disabled={disabled}
|
||||
viewport={viewport}
|
||||
onViewportChange={this.onViewportChange}
|
||||
mapboxApiAccessToken={payload.data.mapboxApiKey}
|
||||
mapStyle={formData.mapbox_style}
|
||||
setControlValue={setControlValue}
|
||||
aggregation
|
||||
>
|
||||
{formData.metric !== null &&
|
||||
<Legend
|
||||
categories={buckets}
|
||||
position={formData.legend_position}
|
||||
/>}
|
||||
</AnimatableDeckGLContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
DeckGLPolygon.propTypes = propTypes;
|
||||
DeckGLPolygon.defaultProps = defaultProps;
|
||||
|
||||
export default DeckGLPolygon;
|
||||
|
||||
@@ -7,7 +7,7 @@ function getPoints(data) {
|
||||
return data.map(d => d.position);
|
||||
}
|
||||
|
||||
export function getLayer(fd, payload, slice) {
|
||||
export function getLayer(fd, payload, onAddFilter, setTooltip) {
|
||||
const dataWithRadius = payload.data.features.map((d) => {
|
||||
let radius = unitToRadius(fd.point_unit, d.radius) || 10;
|
||||
if (fd.multiplier) {
|
||||
@@ -28,7 +28,7 @@ export function getLayer(fd, payload, slice) {
|
||||
radiusMinPixels: fd.min_radius || null,
|
||||
radiusMaxPixels: fd.max_radius || null,
|
||||
outline: false,
|
||||
...commonLayerProps(fd, slice),
|
||||
...commonLayerProps(fd, setTooltip),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ export function getLayer(formData, payload, onAddFilter, setTooltip, filters) {
|
||||
maxColor: [c.r, c.g, c.b, 255 * c.a],
|
||||
outline: false,
|
||||
getWeight: d => d.weight || 0,
|
||||
...commonLayerProps(fd, onAddFilter, setTooltip),
|
||||
...commonLayerProps(fd, setTooltip),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ export function fitViewport(viewport, points, padding = 10) {
|
||||
}
|
||||
}
|
||||
|
||||
export function commonLayerProps(formData, onAddFilter, setTooltip) {
|
||||
export function commonLayerProps(formData, setTooltip, onSelect) {
|
||||
const fd = formData;
|
||||
let onHover;
|
||||
let tooltipContentGenerator;
|
||||
@@ -64,8 +64,8 @@ export function commonLayerProps(formData, onAddFilter, setTooltip) {
|
||||
const href = sandboxedEval(fd.js_onclick_href)(o);
|
||||
window.open(href);
|
||||
};
|
||||
} else if (fd.table_filter && fd.line_type === 'geohash') {
|
||||
onClick = o => onAddFilter(fd.line_column, [o.object[fd.line_column]], false);
|
||||
} else if (fd.table_filter && onSelect !== undefined) {
|
||||
onClick = o => onSelect(o.object[fd.line_column]);
|
||||
}
|
||||
return {
|
||||
onClick,
|
||||
|
||||
92
superset/assets/src/visualizations/deckgl/utils.js
Normal file
92
superset/assets/src/visualizations/deckgl/utils.js
Normal file
@@ -0,0 +1,92 @@
|
||||
import d3 from 'd3';
|
||||
import getSequentialSchemeRegistry from '../../modules/colors/SequentialSchemeRegistrySingleton';
|
||||
import { colorScalerFactory, hexToRGB } from '../../modules/colors';
|
||||
|
||||
export function getBreakPoints({
|
||||
break_points: formDataBreakPoints,
|
||||
num_buckets: formDataNumBuckets,
|
||||
metric,
|
||||
}, features) {
|
||||
if (formDataBreakPoints === undefined || formDataBreakPoints.length === 0) {
|
||||
// compute evenly distributed break points based on number of buckets
|
||||
const numBuckets = formDataNumBuckets
|
||||
? parseInt(formDataNumBuckets, 10)
|
||||
: 10;
|
||||
const [minValue, maxValue] = d3.extent(features, d => d[metric]);
|
||||
const delta = (maxValue - minValue) / numBuckets;
|
||||
const precision = delta === 0
|
||||
? 0
|
||||
: Math.max(0, Math.ceil(Math.log10(1 / delta)));
|
||||
return Array(numBuckets + 1)
|
||||
.fill()
|
||||
.map((_, i) => (minValue + i * delta).toFixed(precision));
|
||||
}
|
||||
return formDataBreakPoints.sort((a, b) => parseFloat(a) - parseFloat(b));
|
||||
}
|
||||
|
||||
export function getBreakPointColorScaler({
|
||||
break_points: formDataBreakPoints,
|
||||
num_buckets: formDataNumBuckets,
|
||||
linear_color_scheme: linearColorScheme,
|
||||
metric,
|
||||
opacity,
|
||||
}, features) {
|
||||
const breakPoints = formDataBreakPoints || formDataNumBuckets
|
||||
? getBreakPoints({
|
||||
break_points: formDataBreakPoints,
|
||||
num_buckets: formDataNumBuckets,
|
||||
metric,
|
||||
}, features)
|
||||
: null;
|
||||
const colors = Array.isArray(linearColorScheme)
|
||||
? linearColorScheme
|
||||
: getSequentialSchemeRegistry().get(linearColorScheme).colors;
|
||||
|
||||
let scaler;
|
||||
let maskPoint;
|
||||
if (breakPoints !== null) {
|
||||
// bucket colors into discrete colors
|
||||
const colorScaler = colorScalerFactory(colors);
|
||||
const n = breakPoints.length - 1;
|
||||
const bucketedColors = n > 1
|
||||
? [...Array(n).keys()].map(d => colorScaler(d / (n - 1)))
|
||||
: [colors[colors.length - 1]];
|
||||
|
||||
// repeat ends
|
||||
bucketedColors.unshift(bucketedColors[0]);
|
||||
bucketedColors.push(bucketedColors[n - 1]);
|
||||
|
||||
const points = breakPoints.map(p => parseFloat(p));
|
||||
scaler = d3.scale.threshold().domain(points).range(bucketedColors);
|
||||
maskPoint = value => value > breakPoints[n] || value < breakPoints[0];
|
||||
} else {
|
||||
// interpolate colors linearly
|
||||
scaler = colorScalerFactory(colors, features, d => d[metric]);
|
||||
maskPoint = () => false;
|
||||
}
|
||||
|
||||
return (d) => {
|
||||
const c = hexToRGB(scaler(d[metric]));
|
||||
if (maskPoint(d[metric])) {
|
||||
c[3] = 0;
|
||||
} else {
|
||||
c[3] = (opacity / 100.0) * 255;
|
||||
}
|
||||
return c;
|
||||
};
|
||||
}
|
||||
|
||||
export function getBuckets(fd, features) {
|
||||
const breakPoints = getBreakPoints(fd, features, true);
|
||||
const colorScaler = getBreakPointColorScaler(fd, features);
|
||||
const buckets = {};
|
||||
breakPoints.slice(1).forEach((value, i) => {
|
||||
const range = breakPoints[i] + ' - ' + breakPoints[i + 1];
|
||||
const mid = 0.5 * (parseInt(breakPoints[i], 10) + parseInt(breakPoints[i + 1], 10));
|
||||
buckets[range] = {
|
||||
color: colorScaler({ [fd.metric]: mid }),
|
||||
enabled: true,
|
||||
};
|
||||
});
|
||||
return buckets;
|
||||
}
|
||||
Reference in New Issue
Block a user