diff --git a/caravel/assets/images/viz_thumbnails/mapbox.png b/caravel/assets/images/viz_thumbnails/mapbox.png new file mode 100644 index 0000000000000..662c163d63ccb Binary files /dev/null and b/caravel/assets/images/viz_thumbnails/mapbox.png differ diff --git a/caravel/assets/javascripts/modules/caravel.js b/caravel/assets/javascripts/modules/caravel.js index 64815da38fec7..282acfb542fa0 100644 --- a/caravel/assets/javascripts/modules/caravel.js +++ b/caravel/assets/javascripts/modules/caravel.js @@ -29,7 +29,8 @@ var sourceMap = { world_map: 'world_map.js', treemap: 'treemap.js', cal_heatmap: 'cal_heatmap.js', - horizon: 'horizon.js' + horizon: 'horizon.js', + mapbox: 'mapbox.jsx' }; var color = function () { diff --git a/caravel/assets/package.json b/caravel/assets/package.json index efc3e64596337..a0872f376a8b1 100644 --- a/caravel/assets/package.json +++ b/caravel/assets/package.json @@ -35,6 +35,7 @@ }, "homepage": "https://github.com/airbnb/caravel#readme", "dependencies": { + "autobind-decorator": "^1.3.3", "babel-loader": "^6.2.1", "babel-polyfill": "^6.3.14", "babel-preset-es2015": "^6.3.13", @@ -43,6 +44,7 @@ "bootstrap-datepicker": "^1.6.0", "bootstrap-toggle": "^2.2.1", "brace": "^0.7.0", + "brfs": "^1.4.3", "cal-heatmap": "3.5.4", "css-loader": "^0.23.1", "d3": "^3.5.14", @@ -58,20 +60,28 @@ "imports-loader": "^0.6.5", "jquery": "^2.2.1", "jquery-ui": "^1.10.5", + "json-loader": "^0.5.4", "less": "^2.6.1", "less-loader": "^2.2.2", + "mapbox-gl": "^0.20.0", "mustache": "^2.2.1", "nvd3": "1.8.3", "react": "^0.14.7", "react-bootstrap": "^0.28.3", "react-dom": "^0.14.7", "react-grid-layout": "^0.12.3", + "react-map-gl": "^1.0.0-beta-10", "react-resizable": "^1.3.3", "select2": "3.5", "select2-bootstrap-css": "^1.4.6", "style-loader": "^0.13.0", + "supercluster": "Pending PR at https://github.com/mapbox/supercluster/pull/12", + "supercluster": "https://github.com/georgeke/supercluster/tarball/ac3492737e7ce98e07af679623aad452373bbc40", "topojson": "^1.6.22", - "webpack": "^1.12.12" + "transform-loader": "^0.2.3", + "viewport-mercator-project": "^2.1.0", + "webpack": "^1.12.12", + "webworkify-webpack": "1.0.6" }, "devDependencies": { "eslint": "^2.2.0", diff --git a/caravel/assets/utils/common.js b/caravel/assets/utils/common.js new file mode 100644 index 0000000000000..82f1841d5db91 --- /dev/null +++ b/caravel/assets/utils/common.js @@ -0,0 +1,27 @@ +const d3 = window.d3 || require('d3'); + +export const EARTH_CIRCUMFERENCE_KM = 40075.16; +export const LUMINANCE_RED_WEIGHT = 0.2126; +export const LUMINANCE_GREEN_WEIGHT = 0.7152; +export const LUMINANCE_BLUE_WEIGHT = 0.0722; +export const MILES_PER_KM = 1.60934; +export const DEFAULT_LONGITUDE = -122.405293; +export const DEFAULT_LATITUDE = 37.772123; +export const DEFAULT_ZOOM = 11; + +export function kmToPixels(kilometers, latitude, zoomLevel) { + // Algorithm from: http://wiki.openstreetmap.org/wiki/Zoom_levels + const latitudeRad = latitude * (Math.PI / 180); + // Seems like the zoomLevel is off by one + const kmPerPixel = EARTH_CIRCUMFERENCE_KM * Math.cos(latitudeRad) / Math.pow(2, zoomLevel + 9); + return d3.round(kilometers / kmPerPixel, 2); +} + +export function isNumeric(num) { + return !isNaN(parseFloat(num)) && isFinite(num); +} + +export function rgbLuminance(r, g, b) { + // Formula: https://en.wikipedia.org/wiki/Relative_luminance + return LUMINANCE_RED_WEIGHT*r + LUMINANCE_GREEN_WEIGHT*g + LUMINANCE_BLUE_WEIGHT*b; +} diff --git a/caravel/assets/visualizations/mapbox.css b/caravel/assets/visualizations/mapbox.css new file mode 100644 index 0000000000000..a64a4ac209770 --- /dev/null +++ b/caravel/assets/visualizations/mapbox.css @@ -0,0 +1,16 @@ +div.widget .slice_container { + cursor: grab; + cursor: -moz-grab; + cursor: -webkit-grab; + overflow: hidden; +} + +div.widget .slice_container:active { + cursor: grabbing; + cursor: -moz-grabbing; + cursor: -webkit-grabbing; +} + +.slice_container div { + padding-top: 0px; +} diff --git a/caravel/assets/visualizations/mapbox.jsx b/caravel/assets/visualizations/mapbox.jsx new file mode 100644 index 0000000000000..97281e9a0decb --- /dev/null +++ b/caravel/assets/visualizations/mapbox.jsx @@ -0,0 +1,336 @@ +const d3 = window.d3 || require('d3'); +require('./mapbox.css'); + +import React from 'react'; +import ReactDOM from 'react-dom'; +import MapGL from 'react-map-gl'; +import ScatterPlotOverlay from 'react-map-gl/src/overlays/scatterplot.react.js'; +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'; + +class ScatterPlotGlowOverlay extends ScatterPlotOverlay { + _drawText(ctx, pixel, options = {}) { + const IS_DARK_THRESHOLD = 110; + const { fontHeight = 0, label = '', radius = 0, rgb = [0, 0, 0], shadow = false } = options; + const maxWidth = radius * 1.8; + const luminance = rgbLuminance(rgb[1], rgb[2], rgb[3]); + + ctx.globalCompositeOperation = 'source-over'; + ctx.fillStyle = luminance <= IS_DARK_THRESHOLD ? 'white' : 'black'; + ctx.font = fontHeight + 'px sans-serif'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + if (shadow) { + ctx.shadowBlur = 15; + ctx.shadowColor = luminance <= IS_DARK_THRESHOLD ? 'black' : ''; + } + + const textWidth = ctx.measureText(label).width; + if (textWidth > maxWidth) { + const scale = fontHeight / textWidth; + ctx.font = scale * maxWidth + 'px sans-serif'; + } + + ctx.fillText(label, pixel[0], pixel[1]); + ctx.globalCompositeOperation = this.props.compositeOperation; + ctx.shadowBlur = 0; + ctx.shadowColor = ''; + } + + // Modified: https://github.com/uber/react-map-gl/blob/master/src/overlays/scatterplot.react.js + _redraw() { + const props = this.props; + const pixelRatio = window.devicePixelRatio || 1; + const canvas = this.refs.overlay; + const ctx = canvas.getContext('2d'); + const radius = props.dotRadius; + const mercator = ViewportMercator(props); + const rgb = props.rgb; + let maxLabel = -1; + let clusterLabelMap = []; + + props.locations.forEach(function (location, i) { + if (location.get('properties').get('cluster')) { + let clusterLabel = location.get('properties').get('metric') + ? location.get('properties').get('metric') + : location.get('properties').get('point_count'); + + if (clusterLabel instanceof Immutable.List) { + clusterLabel = clusterLabel.toArray(); + if (props.aggregatorName === 'mean') { + clusterLabel = d3.mean(clusterLabel); + } else if (props.aggregatorName === 'median') { + clusterLabel = d3.median(clusterLabel); + } else if (props.aggregatorName === 'stdev') { + clusterLabel = d3.deviation(clusterLabel); + } else { + clusterLabel = d3.variance(clusterLabel); + } + } + + clusterLabel = isNumeric(clusterLabel) + ? d3.round(clusterLabel, 2) + : location.get('properties').get('point_count'); + maxLabel = Math.max(clusterLabel, maxLabel); + clusterLabelMap[i] = clusterLabel; + } + }, this); + + ctx.save(); + ctx.scale(pixelRatio, pixelRatio); + ctx.clearRect(0, 0, props.width, props.height); + ctx.globalCompositeOperation = props.compositeOperation; + + if ((props.renderWhileDragging || !props.isDragging) && props.locations) { + props.locations.forEach(function _forEach(location, i) { + const pixel = mercator.project(props.lngLatAccessor(location)); + const pixelRounded = [d3.round(pixel[0], 1), d3.round(pixel[1], 1)]; + + if (pixelRounded[0] + radius >= 0 + && pixelRounded[0] - radius < props.width + && pixelRounded[1] + radius >= 0 + && pixelRounded[1] - radius < props.height) { + + ctx.beginPath(); + if (location.get('properties').get('cluster')) { + let clusterLabel = clusterLabelMap[i]; + const scaledRadius = d3.round(Math.pow(clusterLabel / maxLabel, 0.5) * radius, 1); + const fontHeight = d3.round(scaledRadius * 0.5, 1); + const gradient = ctx.createRadialGradient( + pixelRounded[0], pixelRounded[1], scaledRadius, + pixelRounded[0], pixelRounded[1], 0 + ); + + gradient.addColorStop(1, 'rgba(' + rgb[1] + ', ' + rgb[2] + ', ' + rgb[3] + ', 0.8)'); + gradient.addColorStop(0, 'rgba(' + rgb[1] + ', ' + rgb[2] + ', ' + rgb[3] + ', 0)'); + ctx.arc(pixelRounded[0], pixelRounded[1], scaledRadius, 0, Math.PI * 2); + ctx.fillStyle = gradient; + ctx.fill(); + + if (isNumeric(clusterLabel)) { + clusterLabel = clusterLabel >= 10000 ? Math.round(clusterLabel / 1000) + 'k' : + clusterLabel >= 1000 ? (Math.round(clusterLabel / 100) / 10) + 'k' : + clusterLabel; + this._drawText(ctx, pixelRounded, { + fontHeight: fontHeight, + label: clusterLabel, + radius: scaledRadius, + rgb: rgb, + shadow: true + }); + } + } else { + const defaultRadius = radius / 6; + const radiusProperty = location.get('properties').get('radius'); + const pointMetric = location.get('properties').get('metric'); + let pointRadius = radiusProperty === null ? defaultRadius : radiusProperty; + let pointLabel; + + if (radiusProperty !== null) { + if (props.pointRadiusUnit === 'Kilometers') { + pointLabel = d3.round(pointRadius, 2) + 'km'; + pointRadius = kmToPixels(pointRadius, props.latitude, props.zoom); + } else if (props.pointRadiusUnit === 'Miles') { + pointLabel = d3.round(pointRadius, 2) + 'mi'; + pointRadius = kmToPixels(pointRadius * MILES_PER_KM, props.latitude, props.zoom); + } + } + + if (pointMetric !== null) { + pointLabel = isNumeric(pointMetric) ? d3.round(pointMetric, 2) : pointMetric; + } + + // Fall back to default points if pointRadius wasn't a numerical column + if (!pointRadius) { + pointRadius = defaultRadius; + } + + ctx.arc(pixelRounded[0], pixelRounded[1], d3.round(pointRadius, 1), 0, Math.PI * 2); + ctx.fillStyle = 'rgb(' + rgb[1] + ', ' + rgb[2] + ', ' + rgb[3] + ')'; + ctx.fill(); + + if (pointLabel !== undefined) { + this._drawText(ctx, pixelRounded, { + fontHeight: d3.round(pointRadius, 1), + label: pointLabel, + radius: pointRadius, + rgb: rgb, + shadow: false + }); + } + } + } + }, this); + } + + ctx.restore(); + } +} + +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: longitude, + latitude: latitude, + zoom: this.props.viewportZoom || DEFAULT_ZOOM, + startDragLngLat: [longitude, latitude] + } + }; + + this.onChangeViewport = this.onChangeViewport.bind(this); + } + + onChangeViewport(viewport) { + this.setState({ + viewport: viewport + }); + } + + render() { + const mercator = 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; + + d3.select('#viewport_longitude').attr('value', this.state.viewport.longitude); + d3.select('#viewport_latitude').attr('value', this.state.viewport.latitude); + d3.select('#viewport_zoom').attr('value', this.state.viewport.zoom); + + return ( + + + + ); + } +} + +function mapbox(slice) { + const DEFAULT_POINT_RADIUS = 60; + const DEFAULT_MAX_ZOOM = 16; + const div = d3.select(slice.selector); + let clusterer; + + let render = function () { + + d3.json(slice.jsonEndpoint(), function (error, json) { + + if (error !== null) { + slice.error(error.responseText); + return ''; + } + + // 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; + } else { + if (b instanceof Array) { + b.push(a); + return b; + } + return [a, b]; + } + }; + } + + 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() + ); + + slice.done(json); + }); + }; + + return { + render: render, + resize: function () {} + }; +} + +module.exports = mapbox; diff --git a/caravel/assets/webpack.config.js b/caravel/assets/webpack.config.js index 6f26479f237ad..4d8e963c6faf0 100644 --- a/caravel/assets/webpack.config.js +++ b/caravel/assets/webpack.config.js @@ -17,6 +17,11 @@ var config = { path: BUILD_DIR, filename: '[name].entry.js' }, + resolve: { + alias: { + webworkify: 'webworkify-webpack' + } + }, module: { loaders: [ { @@ -25,6 +30,12 @@ var config = { exclude: APP_DIR + '/node_modules', loader: 'babel' }, + /* for react-map-gl overlays */ + { + test: /\.react\.js$/, + include: APP_DIR + '/node_modules/react-map-gl/src/overlays', + loader: 'babel' + }, /* for require('*.css') */ { test: /\.css$/, @@ -43,8 +54,22 @@ var config = { test: /\.less$/, include: APP_DIR, loader: "style!css!less" + }, + /* for mapbox */ + { + test: /\.json$/, + loader: 'json-loader' + }, { + test: /\.js$/, + include: APP_DIR + '/node_modules/mapbox-gl/js/render/painter/use_program.js', + loader: 'transform/cacheable?brfs' } - ] + ], + postLoaders: [{ + include: /node_modules\/mapbox-gl/, + loader: 'transform', + query: 'brfs' + }] }, plugins: [] }; diff --git a/caravel/bin/caravel b/caravel/bin/caravel index eacd6e15862e4..55911281d9027 100755 --- a/caravel/bin/caravel +++ b/caravel/bin/caravel @@ -94,6 +94,9 @@ def load_examples(load_test_data): print("Loading [Random time series data]") data.load_random_time_series_data() + print("Loading [Random long/lat data]") + data.load_long_lat_data() + if load_test_data: print("Loading [Unicode test data]") data.load_unicode_test_data() diff --git a/caravel/config.py b/caravel/config.py index 4d4f25563d793..4babe71528dbb 100644 --- a/caravel/config.py +++ b/caravel/config.py @@ -173,6 +173,9 @@ INTERVAL = 1 BACKUP_COUNT = 30 +# Set this API key to enable Mapbox visualizations +MAPBOX_API_KEY = "" + try: from caravel_config import * # noqa diff --git a/caravel/data/__init__.py b/caravel/data/__init__.py index 669a283dc05b3..cf42399187704 100644 --- a/caravel/data/__init__.py +++ b/caravel/data/__init__.py @@ -950,3 +950,73 @@ def load_random_time_series_data(): params=get_slice_json(slice_data), ) merge_slice(slc) + + +def load_long_lat_data(): + """Loading lat/long data from a csv file in the repo""" + with gzip.open(os.path.join(DATA_FOLDER, 'san_francisco.csv.gz')) as f: + pdf = pd.read_csv(f, encoding="utf-8") + pdf['date'] = datetime.datetime.now().date() + pdf['occupancy'] = [random.randint(1, 6) for _ in range(len(pdf))] + pdf['radius_miles'] = [random.uniform(1, 3) for _ in range(len(pdf))] + pdf.to_sql( + 'long_lat', + db.engine, + if_exists='replace', + chunksize=500, + dtype={ + 'longitude': Float(), + 'latitude': Float(), + 'number': Float(), + 'street': String(100), + 'unit': String(10), + 'city': String(50), + 'district': String(50), + 'region': String(50), + 'postcode': Float(), + 'id': String(100), + 'date': Date(), + 'occupancy': Float(), + 'radius_miles': Float(), + }, + index=False) + print("Done loading table!") + print("-" * 80) + + print("Creating table reference") + obj = db.session.query(TBL).filter_by(table_name='long_lat').first() + if not obj: + obj = TBL(table_name='long_lat') + obj.main_dttm_col = 'date' + obj.database = get_or_create_db(db.session) + obj.is_featured = False + db.session.merge(obj) + db.session.commit() + obj.fetch_metadata() + tbl = obj + + slice_data = { + "datasource_id": "7", + "datasource_name": "long_lat", + "datasource_type": "table", + "granularity": "day", + "since": "2014-01-01", + "until": "2016-12-12", + "where": "", + "viz_type": "mapbox", + "all_columns_x": "LON", + "all_columns_y": "LAT", + "mapbox_style": "mapbox://styles/mapbox/light-v9", + "all_columns": ["occupancy"], + "row_limit": 500000, + } + + print("Creating a slice") + slc = Slice( + slice_name="Mapbox Long/Lat", + viz_type='mapbox', + datasource_type='table', + table=tbl, + params=get_slice_json(slice_data), + ) + merge_slice(slc) diff --git a/caravel/data/birth_names.csv.gz b/caravel/data/birth_names.csv.gz index 9990ab9ccb65a..14f3ed2df6755 100644 Binary files a/caravel/data/birth_names.csv.gz and b/caravel/data/birth_names.csv.gz differ diff --git a/caravel/data/san_francisco.csv.gz b/caravel/data/san_francisco.csv.gz new file mode 100644 index 0000000000000..1d977a4a1a803 Binary files /dev/null and b/caravel/data/san_francisco.csv.gz differ diff --git a/caravel/forms.py b/caravel/forms.py index 89b14c72c2576..2f9705a4f77bc 100644 --- a/caravel/forms.py +++ b/caravel/forms.py @@ -810,6 +810,110 @@ def __init__(self, viz): "Description text that shows up below your Big " "Number") }), + 'mapbox_label': (SelectMultipleSortableField, { + "label": "Label", + "choices": self.choicify(["count"] + datasource.column_names), + "description": _( + "'count' is COUNT(*) if a group by is used. " + "Numerical columns will be aggregated with the aggregator. " + "Non-numerical columns will be used to label points. " + "Leave empty to get a count of points in each cluster."), + }), + 'mapbox_style': (SelectField, { + "label": "Map Style", + "choices": [ + ("mapbox://styles/mapbox/streets-v9", "Streets"), + ("mapbox://styles/mapbox/dark-v9", "Dark"), + ("mapbox://styles/mapbox/light-v9", "Light"), + ("mapbox://styles/mapbox/satellite-streets-v9", "Satellite Streets"), + ("mapbox://styles/mapbox/satellite-v9", "Satellite"), + ("mapbox://styles/mapbox/outdoors-v9", "Outdoors"), + ], + "description": _("Base layer map style") + }), + 'clustering_radius': (FreeFormSelectField, { + "label": _("Clustering Radius"), + "default": "60", + "choices": self.choicify([ + '0', + '20', + '40', + '60', + '80', + '100', + '200', + '500', + '1000', + ]), + "description": _( + "The radius (in pixels) the algorithm uses to define a cluster. " + "Choose 0 to turn off clustering, but beware that a large " + "number of points (>1000) will cause lag.") + }), + 'point_radius': (SelectField, { + "label": _("Point Radius"), + "default": "Auto", + "choices": self.choicify(["Auto"] + datasource.column_names), + "description": _( + "The radius of individual points (ones that are not in a cluster). " + "Either a numerical column or 'Auto', which scales the point based " + "on the largest cluster") + }), + 'point_radius_unit': (SelectField, { + "label": _("Point Radius Unit"), + "default": "Pixels", + "choices": self.choicify([ + "Pixels", + "Miles", + "Kilometers", + ]), + "description": _("The unit of measure for the specified point radius") + }), + 'global_opacity': (DecimalField, { + "label": _("Opacity"), + "default": 1, + "description": _( + "Opacity of all clusters, points, and labels. " + "Between 0 and 1."), + }), + 'viewport_zoom': (DecimalField, { + "label": _("Zoom"), + "default": 11, + "validators": [validators.optional()], + "description": _("Zoom level of the map"), + "places": 8, + }), + 'viewport_latitude': (DecimalField, { + "label": _("Default latitude"), + "default": 37.772123, + "description": _("Latitude of default viewport"), + "places": 8, + }), + 'viewport_longitude': (DecimalField, { + "label": _("Default longitude"), + "default": -122.405293, + "description": _("Longitude of default viewport"), + "places": 8, + }), + 'render_while_dragging': (BetterBooleanField, { + "label": _("Live render"), + "default": True, + "description": _("Points and clusters will update as viewport " + "is being changed") + }), + 'mapbox_color': (FreeFormSelectField, { + "label": _("RGB Color"), + "default": "rgb(0, 122, 135)", + "choices": [ + ("rgb(0, 139, 139)", "Dark Cyan"), + ("rgb(128, 0, 128)", "Purple"), + ("rgb(255, 215, 0)", "Gold"), + ("rgb(69, 69, 69)", "Dim Gray"), + ("rgb(220, 20, 60)", "Crimson"), + ("rgb(34, 139, 34)", "Forest Green"), + ], + "description": _("The color for points and clusters in RGB") + }), } # Override default arguments with form overrides diff --git a/caravel/views.py b/caravel/views.py index 3004dd563ebe1..f86d32f7d7436 100644 --- a/caravel/views.py +++ b/caravel/views.py @@ -844,7 +844,7 @@ def save_or_overwrite_slice(self, args, slc, slice_add_perm, slice_edit_perm): d = args.to_dict(flat=False) del d['action'] del d['previous_viz_type'] - as_list = ('metrics', 'groupby', 'columns', 'all_columns') + as_list = ('metrics', 'groupby', 'columns', 'all_columns', 'mapbox_label') for k in d: v = d.get(k) if k in as_list and not isinstance(v, list): diff --git a/caravel/viz.py b/caravel/viz.py index cfef8b14528df..5bf4aada30671 100644 --- a/caravel/viz.py +++ b/caravel/viz.py @@ -1666,6 +1666,176 @@ class HorizonViz(NVD3TimeSeriesViz): ), }] +class MapboxViz(BaseViz): + + """Rich maps made with Mapbox""" + + viz_type = "mapbox" + verbose_name = _("Mapbox") + is_timeseries = False + credits = ( + 'Mapbox GL JS') + fieldsets = ({ + 'label': None, + 'fields': ( + ('all_columns_x', 'all_columns_y'), + 'clustering_radius', + 'row_limit', + 'groupby', + 'render_while_dragging', + ) + }, { + 'label': 'Points', + 'fields': ( + 'point_radius', + 'point_radius_unit', + ) + }, { + 'label': 'Labelling', + 'fields': ( + 'mapbox_label', + 'pandas_aggfunc', + ) + }, { + 'label': 'Visual Tweaks', + 'fields': ( + 'mapbox_style', + 'global_opacity', + 'mapbox_color', + ) + }, { + 'label': 'Viewport', + 'fields': ( + 'viewport_longitude', + 'viewport_latitude', + 'viewport_zoom', + ) + },) + + form_overrides = { + 'all_columns_x': { + 'label': 'Longitude', + 'description': "Column containing longitude data", + }, + 'all_columns_y': { + 'label': 'Latitude', + 'description': "Column containing latitude data", + }, + 'pandas_aggfunc': { + 'label': 'Cluster label aggregator', + 'description': _( + "Aggregate function applied to the list of points " + "in each cluster to produce the cluster label."), + }, + 'rich_tooltip': { + 'label': 'Tooltip', + 'description': _( + "Show a tooltip when hovering over points and clusters " + "describing the label"), + }, + 'groupby': { + 'description': _( + "One or many fields to group by. If grouping, latitude " + "and longitude columns must be present."), + }, + } + + def query_obj(self): + d = super(MapboxViz, self).query_obj() + fd = self.form_data + label_col = fd.get('mapbox_label') + + if not fd.get('groupby'): + d['columns'] = [fd.get('all_columns_x'), fd.get('all_columns_y')] + + if label_col and len(label_col) >= 1: + if label_col[0] == "count": + raise Exception( + "Must have a [Group By] column to have 'count' as the [Label]") + d['columns'].append(label_col[0]) + + if fd.get('point_radius') != 'Auto': + d['columns'].append(fd.get('point_radius')) + + d['columns'] = list(set(d['columns'])) + else: + # Ensuring columns chosen are all in group by + if (label_col and len(label_col) >= 1 and + label_col[0] != "count" and + label_col[0] not in fd.get('groupby')): + raise Exception( + "Choice of [Label] must be present in [Group By]") + + if (fd.get("point_radius") != "Auto" and + fd.get("point_radius") not in fd.get('groupby')): + raise Exception( + "Choice of [Point Radius] must be present in [Group By]") + + if (fd.get('all_columns_x') not in fd.get('groupby') or + fd.get('all_columns_y') not in fd.get('groupby')): + raise Exception( + "[Longitude] and [Latitude] columns must be present in [Group By]") + return d + + def get_data(self): + df = self.get_df() + fd = self.form_data + label_col = fd.get('mapbox_label') + custom_metric = label_col and len(label_col) >= 1 + metric_col = [None] * len(df.index) + if custom_metric: + if label_col[0] == fd.get('all_columns_x'): + metric_col = df[fd.get('all_columns_x')] + elif label_col[0] == fd.get('all_columns_y'): + metric_col = df[fd.get('all_columns_y')] + else: + metric_col = df[label_col[0]] + point_radius_col = ( + [None] * len(df.index) + if fd.get("point_radius") == "Auto" + else df[fd.get("point_radius")]) + + # using geoJSON formatting + geo_json = { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "properties": { + "metric": metric, + "radius": point_radius, + }, + "geometry": { + "type": "Point", + "coordinates": [lon, lat], + } + } + for lon, lat, metric, point_radius + in zip( + df[fd.get('all_columns_x')], + df[fd.get('all_columns_y')], + metric_col, point_radius_col) + ] + } + + return { + "geoJSON": geo_json, + "customMetric": custom_metric, + "mapboxApiKey": config.get('MAPBOX_API_KEY'), + "mapStyle": fd.get("mapbox_style"), + "aggregatorName": fd.get("pandas_aggfunc"), + "clusteringRadius": fd.get("clustering_radius"), + "pointRadiusUnit": fd.get("point_radius_unit"), + "globalOpacity": fd.get("global_opacity"), + "viewportLongitude": fd.get("viewport_longitude"), + "viewportLatitude": fd.get("viewport_latitude"), + "viewportZoom": fd.get("viewport_zoom"), + "renderWhileDragging": fd.get("render_while_dragging"), + "tooltip": fd.get("rich_tooltip"), + "color": fd.get("mapbox_color"), + } + + viz_types_list = [ TableViz, PivotTableViz, @@ -1692,6 +1862,7 @@ class HorizonViz(NVD3TimeSeriesViz): TreemapViz, CalHeatmapViz, HorizonViz, + MapboxViz, ] viz_types = OrderedDict([(v.viz_type, v) for v in viz_types_list diff --git a/docs/gallery.rst b/docs/gallery.rst index d44f184f28596..30639fbca5b9e 100644 --- a/docs/gallery.rst +++ b/docs/gallery.rst @@ -76,3 +76,5 @@ Gallery .. image:: _static/img/viz_thumbnails/horizon.png :scale: 25 % +.. image:: _static/img/viz_thumbnails/mapbox.png + :scale: 25 %