You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by cc...@apache.org on 2018/09/05 19:12:32 UTC
[incubator-superset] branch master updated: [SIP-5] Refactor MapBox
(#5783)
This is an automated email from the ASF dual-hosted git repository.
ccwilliams pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/incubator-superset.git
The following commit(s) were added to refs/heads/master by this push:
new bebbdb8 [SIP-5] Refactor MapBox (#5783)
bebbdb8 is described below
commit bebbdb85d2cc301d2cb86dc6a6da46a285cdd7da
Author: Krist Wongsuphasawat <kr...@gmail.com>
AuthorDate: Wed Sep 5 12:12:30 2018 -0700
[SIP-5] Refactor MapBox (#5783)
* Break MapBox into smaller pieces
* Replace React.createElement with regular jsx
* detach setControlValue
* enable render trigger
* Pass explicit props rather than pass all that exists in payload.data. Also use formData when possible.
* Rename sliceWidth, sliceHeight to width, height. Use deconstructor. Extract function.
* use arrow function
* fix linting and remove css
---
superset/assets/src/explore/controls.jsx | 3 +
.../assets/src/visualizations/MapBox/MapBox.css | 3 +
.../assets/src/visualizations/MapBox/MapBox.jsx | 225 +++++++++++++++++++
.../ScatterPlotGlowOverlay.jsx} | 246 ++++-----------------
superset/assets/src/visualizations/index.js | 2 +-
superset/assets/src/visualizations/mapbox.css | 16 --
6 files changed, 281 insertions(+), 214 deletions(-)
diff --git a/superset/assets/src/explore/controls.jsx b/superset/assets/src/explore/controls.jsx
index 152d4a7..5e422ea 100644
--- a/superset/assets/src/explore/controls.jsx
+++ b/superset/assets/src/explore/controls.jsx
@@ -1802,6 +1802,7 @@ export const controls = {
viewport_zoom: {
type: 'TextControl',
label: t('Zoom'),
+ renderTrigger: true,
isFloat: true,
default: 11,
description: t('Zoom level of the map'),
@@ -1813,6 +1814,7 @@ export const controls = {
viewport_latitude: {
type: 'TextControl',
label: t('Default latitude'),
+ renderTrigger: true,
default: 37.772123,
isFloat: true,
description: t('Latitude of default viewport'),
@@ -1824,6 +1826,7 @@ export const controls = {
viewport_longitude: {
type: 'TextControl',
label: t('Default longitude'),
+ renderTrigger: true,
default: -122.405293,
isFloat: true,
description: t('Longitude of default viewport'),
diff --git a/superset/assets/src/visualizations/MapBox/MapBox.css b/superset/assets/src/visualizations/MapBox/MapBox.css
new file mode 100644
index 0000000..3ec640d
--- /dev/null
+++ b/superset/assets/src/visualizations/MapBox/MapBox.css
@@ -0,0 +1,3 @@
+.mapbox .slice_container div {
+ padding-top: 0px;
+}
diff --git a/superset/assets/src/visualizations/MapBox/MapBox.jsx b/superset/assets/src/visualizations/MapBox/MapBox.jsx
new file mode 100644
index 0000000..81f41f0
--- /dev/null
+++ b/superset/assets/src/visualizations/MapBox/MapBox.jsx
@@ -0,0 +1,225 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ReactDOM from 'react-dom';
+import MapGL from 'react-map-gl';
+import Immutable from 'immutable';
+import supercluster from 'supercluster';
+import ViewportMercator from 'viewport-mercator-project';
+import ScatterPlotGlowOverlay from './ScatterPlotGlowOverlay';
+
+import {
+ DEFAULT_LONGITUDE,
+ DEFAULT_LATITUDE,
+ DEFAULT_ZOOM,
+} from '../../utils/common';
+import './MapBox.css';
+
+const NOOP = () => {};
+const DEFAULT_POINT_RADIUS = 60;
+const DEFAULT_MAX_ZOOM = 16;
+
+const propTypes = {
+ width: PropTypes.number,
+ height: PropTypes.number,
+ aggregatorName: PropTypes.string,
+ clusterer: PropTypes.object,
+ globalOpacity: PropTypes.number,
+ mapStyle: PropTypes.string,
+ mapboxApiKey: PropTypes.string,
+ onViewportChange: PropTypes.func,
+ pointRadius: PropTypes.number,
+ pointRadiusUnit: PropTypes.string,
+ renderWhileDragging: PropTypes.bool,
+ rgb: PropTypes.array,
+ viewportLatitude: PropTypes.number,
+ viewportLongitude: PropTypes.number,
+ viewportZoom: PropTypes.number,
+};
+
+const defaultProps = {
+ globalOpacity: 1,
+ onViewportChange: NOOP,
+ pointRadius: DEFAULT_POINT_RADIUS,
+ pointRadiusUnit: 'Pixels',
+ viewportLatitude: DEFAULT_LATITUDE,
+ viewportLongitude: DEFAULT_LONGITUDE,
+ viewportZoom: DEFAULT_ZOOM,
+};
+
+class MapBox extends React.Component {
+ constructor(props) {
+ super(props);
+
+ const {
+ viewportLatitude: latitude,
+ viewportLongitude: longitude,
+ viewportZoom: zoom,
+ } = this.props;
+
+ this.state = {
+ viewport: {
+ longitude,
+ latitude,
+ zoom,
+ startDragLngLat: [longitude, latitude],
+ },
+ };
+ this.onViewportChange = this.onViewportChange.bind(this);
+ }
+
+ onViewportChange(viewport) {
+ this.setState({ viewport });
+ this.props.onViewportChange(viewport);
+ }
+
+ render() {
+ const {
+ width,
+ height,
+ aggregatorName,
+ globalOpacity,
+ mapStyle,
+ mapboxApiKey,
+ pointRadius,
+ pointRadiusUnit,
+ renderWhileDragging,
+ rgb,
+ } = this.props;
+ const { viewport } = this.state;
+ const { latitude, longitude, zoom } = viewport;
+ const mercator = new ViewportMercator({
+ width,
+ height,
+ longitude,
+ latitude,
+ zoom,
+ });
+ const topLeft = mercator.unproject([0, 0]);
+ const bottomRight = mercator.unproject([width, height]);
+ const bbox = [topLeft[0], bottomRight[1], bottomRight[0], topLeft[1]];
+ const clusters = this.props.clusterer.getClusters(bbox, Math.round(zoom));
+ const isDragging = viewport.isDragging === undefined ? false :
+ viewport.isDragging;
+ return (
+ <MapGL
+ {...this.state.viewport}
+ mapStyle={mapStyle}
+ width={width}
+ height={height}
+ mapboxApiAccessToken={mapboxApiKey}
+ onViewportChange={this.onViewportChange}
+ >
+ <ScatterPlotGlowOverlay
+ {...viewport}
+ isDragging={isDragging}
+ width={width}
+ height={height}
+ locations={Immutable.fromJS(clusters)}
+ dotRadius={pointRadius}
+ pointRadiusUnit={pointRadiusUnit}
+ rgb={rgb}
+ globalOpacity={globalOpacity}
+ compositeOperation={'screen'}
+ renderWhileDragging={renderWhileDragging}
+ aggregatorName={aggregatorName}
+ lngLatAccessor={(location) => {
+ const coordinates = location.get('geometry').get('coordinates');
+ return [coordinates.get(0), coordinates.get(1)];
+ }}
+ />
+ </MapGL>
+ );
+ }
+}
+
+MapBox.propTypes = propTypes;
+MapBox.defaultProps = defaultProps;
+
+function createReducer(aggregatorName, customMetric) {
+ if (aggregatorName === 'sum' || !customMetric) {
+ return (a, b) => a + b;
+ } else if (aggName === 'min') {
+ return Math.min;
+ } else if (aggName === 'max') {
+ return Math.max;
+ }
+ return function (a, b) {
+ if (a instanceof Array) {
+ if (b instanceof Array) {
+ return a.concat(b);
+ }
+ a.push(b);
+ return a;
+ }
+ if (b instanceof Array) {
+ b.push(a);
+ return b;
+ }
+ return [a, b];
+ };
+}
+
+function mapbox(slice, payload, setControlValue) {
+ const { formData, selector } = slice;
+ const {
+ customMetric,
+ geoJSON,
+ mapboxApiKey,
+ } = payload.data;
+ const {
+ clustering_radius: clusteringRadius,
+ global_opacity: globalOpacity,
+ mapbox_color: color,
+ mapbox_style: mapStyle,
+ pandas_aggfunc: aggregatorName,
+ point_radius: pointRadius,
+ point_radius_unit: pointRadiusUnit,
+ render_while_dragging: renderWhileDragging,
+ viewport_latitude: viewportLatitude,
+ viewport_longitude: viewportLongitude,
+ viewport_zoom: viewportZoom,
+ } = formData;
+
+ // Validate mapbox color
+ const rgb = /^rgb\((\d{1,3}),\s*(\d{1,3}),\s*(\d{1,3})\)$/
+ .exec(color);
+ if (rgb === null) {
+ slice.error('Color field must be of form \'rgb(%d, %d, %d)\'');
+ return;
+ }
+
+ const clusterer = supercluster({
+ radius: clusteringRadius,
+ maxZoom: DEFAULT_MAX_ZOOM,
+ metricKey: 'metric',
+ metricReducer: createReducer(aggregatorName, customMetric),
+ });
+ clusterer.load(geoJSON.features);
+
+ ReactDOM.render(
+ <MapBox
+ width={slice.width()}
+ height={slice.height()}
+ aggregatorName={aggregatorName}
+ clusterer={clusterer}
+ globalOpacity={globalOpacity}
+ mapStyle={mapStyle}
+ mapboxApiKey={mapboxApiKey}
+ onViewportChange={({ latitude, longitude, zoom }) => {
+ setControlValue('viewport_longitude', longitude);
+ setControlValue('viewport_latitude', latitude);
+ setControlValue('viewport_zoom', zoom);
+ }}
+ pointRadius={pointRadius === 'Auto' ? DEFAULT_POINT_RADIUS : pointRadius}
+ pointRadiusUnit={pointRadiusUnit}
+ renderWhileDragging={renderWhileDragging}
+ rgb={rgb}
+ viewportLatitude={viewportLatitude}
+ viewportLongitude={viewportLongitude}
+ viewportZoom={viewportZoom}
+ />,
+ document.querySelector(selector),
+ );
+}
+
+export default mapbox;
diff --git a/superset/assets/src/visualizations/mapbox.jsx b/superset/assets/src/visualizations/MapBox/ScatterPlotGlowOverlay.jsx
similarity index 59%
rename from superset/assets/src/visualizations/mapbox.jsx
rename to superset/assets/src/visualizations/MapBox/ScatterPlotGlowOverlay.jsx
index 1a156ae..ea4e115 100644
--- a/superset/assets/src/visualizations/mapbox.jsx
+++ b/superset/assets/src/visualizations/MapBox/ScatterPlotGlowOverlay.jsx
@@ -1,28 +1,46 @@
-/* eslint-disable no-param-reassign */
-/* eslint-disable react/no-multi-comp */
import d3 from 'd3';
+import Immutable from 'immutable';
import React from 'react';
import PropTypes from 'prop-types';
-import ReactDOM from 'react-dom';
-import MapGL from 'react-map-gl';
-import Immutable from 'immutable';
-import supercluster from 'supercluster';
import ViewportMercator from 'viewport-mercator-project';
-
import {
kmToPixels,
rgbLuminance,
isNumeric,
MILES_PER_KM,
- DEFAULT_LONGITUDE,
- DEFAULT_LATITUDE,
- DEFAULT_ZOOM,
-} from '../utils/common';
-import './mapbox.css';
+} from '../../utils/common';
+
+const propTypes = {
+ locations: PropTypes.instanceOf(Immutable.List).isRequired,
+ lngLatAccessor: PropTypes.func,
+ renderWhileDragging: PropTypes.bool,
+ globalOpacity: PropTypes.number,
+ dotRadius: PropTypes.number,
+ dotFill: PropTypes.string,
+ compositeOperation: PropTypes.string,
+};
+
+const defaultProps = {
+ lngLatAccessor: location => [location.get(0), location.get(1)],
+ renderWhileDragging: true,
+ dotRadius: 4,
+ dotFill: '#1FBAD6',
+ globalOpacity: 1,
+ // Same as browser default.
+ compositeOperation: 'source-over',
+};
-const NOOP = () => {};
+const contextTypes = {
+ viewport: PropTypes.object,
+ isDragging: PropTypes.bool,
+};
class ScatterPlotGlowOverlay extends React.Component {
+ constructor(props) {
+ super(props);
+ this.setCanvasRef = this.setCanvasRef.bind(this);
+ }
+
componentDidMount() {
this.redraw();
}
@@ -30,6 +48,11 @@ class ScatterPlotGlowOverlay extends React.Component {
componentDidUpdate() {
this.redraw();
}
+
+ setCanvasRef(element) {
+ this.canvas = element;
+ }
+
drawText(ctx, pixel, options = {}) {
const IS_DARK_THRESHOLD = 110;
const { fontHeight = 0, label = '', radius = 0, rgb = [0, 0, 0], shadow = false } = options;
@@ -62,8 +85,7 @@ class ScatterPlotGlowOverlay extends React.Component {
redraw() {
const props = this.props;
const pixelRatio = window.devicePixelRatio || 1;
- const canvas = this.refs.overlay;
- const ctx = canvas.getContext('2d');
+ const ctx = this.canvas.getContext('2d');
const radius = props.dotRadius;
const mercator = new ViewportMercator(props);
const rgb = props.rgb;
@@ -185,9 +207,9 @@ class ScatterPlotGlowOverlay extends React.Component {
}
}, this);
}
-
ctx.restore();
}
+
render() {
let width = 0;
let height = 0;
@@ -198,11 +220,11 @@ class ScatterPlotGlowOverlay extends React.Component {
const { globalOpacity } = this.props;
const pixelRatio = window.devicePixelRatio || 1;
return (
- React.createElement('canvas', {
- ref: 'overlay',
- width: width * pixelRatio,
- height: height * pixelRatio,
- style: {
+ <canvas
+ ref={this.setCanvasRef}
+ width={width * pixelRatio}
+ height={height * pixelRatio}
+ style={{
width: `${width}px`,
height: `${height}px`,
position: 'absolute',
@@ -210,184 +232,14 @@ class ScatterPlotGlowOverlay extends React.Component {
opacity: globalOpacity,
left: 0,
top: 0,
- },
- })
- );
- }
-}
-ScatterPlotGlowOverlay.propTypes = {
- locations: PropTypes.instanceOf(Immutable.List).isRequired,
- lngLatAccessor: PropTypes.func,
- renderWhileDragging: PropTypes.bool,
- globalOpacity: PropTypes.number,
- dotRadius: PropTypes.number,
- dotFill: PropTypes.string,
- compositeOperation: PropTypes.string,
-};
-
-ScatterPlotGlowOverlay.defaultProps = {
- lngLatAccessor: location => [location.get(0), location.get(1)],
- renderWhileDragging: true,
- dotRadius: 4,
- dotFill: '#1FBAD6',
- globalOpacity: 1,
- // Same as browser default.
- compositeOperation: 'source-over',
-};
-ScatterPlotGlowOverlay.contextTypes = {
- viewport: PropTypes.object,
- isDragging: PropTypes.bool,
-};
-
-class MapboxViz extends React.Component {
- constructor(props) {
- super(props);
- const longitude = this.props.viewportLongitude || DEFAULT_LONGITUDE;
- const latitude = this.props.viewportLatitude || DEFAULT_LATITUDE;
-
- this.state = {
- viewport: {
- longitude,
- latitude,
- zoom: this.props.viewportZoom || DEFAULT_ZOOM,
- startDragLngLat: [longitude, latitude],
- },
- };
- this.onViewportChange = this.onViewportChange.bind(this);
- }
-
- onViewportChange(viewport) {
- this.setState({ viewport });
- this.props.setControlValue('viewport_longitude', viewport.longitude);
- this.props.setControlValue('viewport_latitude', viewport.latitude);
- this.props.setControlValue('viewport_zoom', viewport.zoom);
- }
-
- render() {
- const mercator = new ViewportMercator({
- width: this.props.sliceWidth,
- height: this.props.sliceHeight,
- longitude: this.state.viewport.longitude,
- latitude: this.state.viewport.latitude,
- zoom: this.state.viewport.zoom,
- });
- const topLeft = mercator.unproject([0, 0]);
- const bottomRight = mercator.unproject([this.props.sliceWidth, this.props.sliceHeight]);
- const bbox = [topLeft[0], bottomRight[1], bottomRight[0], topLeft[1]];
- const clusters = this.props.clusterer.getClusters(bbox, Math.round(this.state.viewport.zoom));
- const isDragging = this.state.viewport.isDragging === undefined ? false :
- this.state.viewport.isDragging;
- return (
- <MapGL
- {...this.state.viewport}
- mapStyle={this.props.mapStyle}
- width={this.props.sliceWidth}
- height={this.props.sliceHeight}
- mapboxApiAccessToken={this.props.mapboxApiKey}
- onViewportChange={this.onViewportChange}
- >
- <ScatterPlotGlowOverlay
- {...this.state.viewport}
- isDragging={isDragging}
- width={this.props.sliceWidth}
- height={this.props.sliceHeight}
- locations={Immutable.fromJS(clusters)}
- dotRadius={this.props.pointRadius}
- pointRadiusUnit={this.props.pointRadiusUnit}
- rgb={this.props.rgb}
- globalOpacity={this.props.globalOpacity}
- compositeOperation={'screen'}
- renderWhileDragging={this.props.renderWhileDragging}
- aggregatorName={this.props.aggregatorName}
- lngLatAccessor={function (location) {
- const coordinates = location.get('geometry').get('coordinates');
- return [coordinates.get(0), coordinates.get(1)];
- }}
- />
- </MapGL>
+ }}
+ />
);
}
}
-MapboxViz.propTypes = {
- aggregatorName: PropTypes.string,
- clusterer: PropTypes.object,
- setControlValue: PropTypes.func,
- globalOpacity: PropTypes.number,
- mapStyle: PropTypes.string,
- mapboxApiKey: PropTypes.string,
- pointRadius: PropTypes.number,
- pointRadiusUnit: PropTypes.string,
- renderWhileDragging: PropTypes.bool,
- rgb: PropTypes.array,
- sliceHeight: PropTypes.number,
- sliceWidth: PropTypes.number,
- viewportLatitude: PropTypes.number,
- viewportLongitude: PropTypes.number,
- viewportZoom: PropTypes.number,
-};
-
-function mapbox(slice, json, setControlValue) {
- const div = d3.select(slice.selector);
- const DEFAULT_POINT_RADIUS = 60;
- const DEFAULT_MAX_ZOOM = 16;
- // Validate mapbox color
- const rgb = /^rgb\((\d{1,3}),\s*(\d{1,3}),\s*(\d{1,3})\)$/.exec(json.data.color);
- if (rgb === null) {
- slice.error('Color field must be of form \'rgb(%d, %d, %d)\'');
- return;
- }
-
- const aggName = json.data.aggregatorName;
- let reducer;
-
- if (aggName === 'sum' || !json.data.customMetric) {
- reducer = function (a, b) {
- return a + b;
- };
- } else if (aggName === 'min') {
- reducer = Math.min;
- } else if (aggName === 'max') {
- reducer = Math.max;
- } else {
- reducer = function (a, b) {
- if (a instanceof Array) {
- if (b instanceof Array) {
- return a.concat(b);
- }
- a.push(b);
- return a;
- }
- if (b instanceof Array) {
- b.push(a);
- return b;
- }
- return [a, b];
- };
- }
-
- const clusterer = supercluster({
- radius: json.data.clusteringRadius,
- maxZoom: DEFAULT_MAX_ZOOM,
- metricKey: 'metric',
- metricReducer: reducer,
- });
- clusterer.load(json.data.geoJSON.features);
-
- div.selectAll('*').remove();
- ReactDOM.render(
- <MapboxViz
- {...json.data}
- rgb={rgb}
- sliceHeight={slice.height()}
- sliceWidth={slice.width()}
- clusterer={clusterer}
- pointRadius={DEFAULT_POINT_RADIUS}
- aggregatorName={aggName}
- setControlValue={setControlValue || NOOP}
- />,
- div.node(),
- );
-}
+ScatterPlotGlowOverlay.propTypes = propTypes;
+ScatterPlotGlowOverlay.defaultProps = defaultProps;
+ScatterPlotGlowOverlay.contextTypes = contextTypes;
-module.exports = mapbox;
+export default ScatterPlotGlowOverlay;
diff --git a/superset/assets/src/visualizations/index.js b/superset/assets/src/visualizations/index.js
index 7cccf1f..66cff81 100644
--- a/superset/assets/src/visualizations/index.js
+++ b/superset/assets/src/visualizations/index.js
@@ -89,7 +89,7 @@ const vizMap = {
[VIZ_TYPES.line_multi]: () =>
loadVis(import(/* webpackChunkName: "line_multi" */ './line_multi.js')),
[VIZ_TYPES.time_pivot]: loadNvd3,
- [VIZ_TYPES.mapbox]: () => loadVis(import(/* webpackChunkName: "mapbox" */ './mapbox.jsx')),
+ [VIZ_TYPES.mapbox]: () => loadVis(import(/* webpackChunkName: "mapbox" */ './MapBox/MapBox.jsx')),
[VIZ_TYPES.markup]: () => loadVis(import(/* webpackChunkName: "markup" */ './markup.js')),
[VIZ_TYPES.para]: () =>
loadVis(import(/* webpackChunkName: "parallel_coordinates" */ './parallel_coordinates.js')),
diff --git a/superset/assets/src/visualizations/mapbox.css b/superset/assets/src/visualizations/mapbox.css
deleted file mode 100644
index babb33b..0000000
--- a/superset/assets/src/visualizations/mapbox.css
+++ /dev/null
@@ -1,16 +0,0 @@
-.mapbox div.widget .slice_container {
- cursor: grab;
- cursor: -moz-grab;
- cursor: -webkit-grab;
- overflow: hidden;
-}
-
-.mapbox div.widget .slice_container:active {
- cursor: grabbing;
- cursor: -moz-grabbing;
- cursor: -webkit-grabbing;
-}
-
-.mapbox .slice_container div {
- padding-top: 0px;
-}