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 %