From 4b92cb4f493d3e28026d55c6e53427b12e0f6d12 Mon Sep 17 00:00:00 2001 From: Krist Wongsuphasawat Date: Wed, 5 Sep 2018 12:12:30 -0700 Subject: [PATCH] [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 (cherry picked from commit bebbdb85d2cc301d2cb86dc6a6da46a285cdd7da) --- superset/assets/src/explore/controls.jsx | 3 + .../src/visualizations/MapBox/MapBox.css | 3 + .../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(-) create mode 100644 superset/assets/src/visualizations/MapBox/MapBox.css create mode 100644 superset/assets/src/visualizations/MapBox/MapBox.jsx rename superset/assets/src/visualizations/{mapbox.jsx => MapBox/ScatterPlotGlowOverlay.jsx} (59%) delete mode 100644 superset/assets/src/visualizations/mapbox.css diff --git a/superset/assets/src/explore/controls.jsx b/superset/assets/src/explore/controls.jsx index 2fc289ffe2f9..1acd0fb41941 100644 --- a/superset/assets/src/explore/controls.jsx +++ b/superset/assets/src/explore/controls.jsx @@ -1837,6 +1837,7 @@ export const controls = { viewport_zoom: { type: 'TextControl', label: t('Zoom'), + renderTrigger: true, isFloat: true, default: 11, description: t('Zoom level of the map'), @@ -1846,6 +1847,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'), @@ -1855,6 +1857,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 000000000000..3ec640dac6e9 --- /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 000000000000..81f41f074b1d --- /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 ( + + { + const coordinates = location.get('geometry').get('coordinates'); + return [coordinates.get(0), coordinates.get(1)]; + }} + /> + + ); + } +} + +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( + { + 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 1a156ae520e9..ea4e115de3b0 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: { + [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 ( - - - + }} + /> ); } } -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( - , - 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 5192b09d1aa8..f50c17f7d9a8 100644 --- a/superset/assets/src/visualizations/index.js +++ b/superset/assets/src/visualizations/index.js @@ -90,7 +90,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 babb33be0eac..000000000000 --- 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; -}