mirror of https://github.com/apache/superset.git
Map visualization (#650)
* simple mapbox viz use react-map-gl superclustering of long/lat points Added hook for map style, huge performance boost from bounding box fix, added count text on clusters variable gradient size based on metric count Ability to aggregate over any point property This needed a change in the supercluster npm module, a PR was placed here: https://github.com/mapbox/supercluster/pull/12 Aggregator function option in explore, tweaked visual defaults better radius size management clustering radius, point metric/unit options scale cluster labels that don't fit, non-numeric labels for points Minor fixes, label field affects points, text changes serve mapbox apikey for slice global opacity, viewport saves (hacky), bug in point labels fixing mapbox-gl dependency mapbox_api_key in config expose row_limit, fix minor bugs Add renderWhileDragging flag, groupby. Only show numerical columns for point radius Implicitly group by lng/lat columns and error when label doesn't match groupby 'Fix' radius in miles problem, still some jankiness derived fields cannot be typed as of now -> reverting numerical number change better grouping error checking, expose count(*) for labelling Custom colour for clusters/points + smart text colouring Fixed bad positioning and overflow in explore view + small bugs + added thumbnail * landscaping & eslint & use izip * landscapin' * address js code review
This commit is contained in:
parent
914f23432f
commit
57ebb2bacf
Binary file not shown.
After Width: | Height: | Size: 220 KiB |
|
@ -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 () {
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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 (
|
||||
<MapGL
|
||||
{...this.state.viewport}
|
||||
mapStyle={this.props.mapStyle}
|
||||
width={this.props.sliceWidth}
|
||||
height={this.props.sliceHeight}
|
||||
mapboxApiAccessToken={this.props.mapboxApiKey}
|
||||
onChangeViewport={this.onChangeViewport}>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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(
|
||||
<MapboxViz
|
||||
{...json.data}
|
||||
rgb={rgb}
|
||||
sliceHeight={slice.height()}
|
||||
sliceWidth={slice.width()}
|
||||
clusterer={clusterer}
|
||||
pointRadius={DEFAULT_POINT_RADIUS}
|
||||
aggregatorName={aggName}/>,
|
||||
div.node()
|
||||
);
|
||||
|
||||
slice.done(json);
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
render: render,
|
||||
resize: function () {}
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = mapbox;
|
|
@ -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: []
|
||||
};
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -173,6 +173,9 @@ ROLLOVER = 'midnight'
|
|||
INTERVAL = 1
|
||||
BACKUP_COUNT = 30
|
||||
|
||||
# Set this API key to enable Mapbox visualizations
|
||||
MAPBOX_API_KEY = ""
|
||||
|
||||
|
||||
try:
|
||||
from caravel_config import * # noqa
|
||||
|
|
|
@ -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)
|
||||
|
|
Binary file not shown.
Binary file not shown.
104
caravel/forms.py
104
caravel/forms.py
|
@ -810,6 +810,110 @@ class FormFactory(object):
|
|||
"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
|
||||
|
|
|
@ -844,7 +844,7 @@ class Caravel(BaseCaravelView):
|
|||
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):
|
||||
|
|
171
caravel/viz.py
171
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 = (
|
||||
'<a href=https://www.mapbox.com/mapbox-gl-js/api/>Mapbox GL JS</a>')
|
||||
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 @@ viz_types_list = [
|
|||
TreemapViz,
|
||||
CalHeatmapViz,
|
||||
HorizonViz,
|
||||
MapboxViz,
|
||||
]
|
||||
|
||||
viz_types = OrderedDict([(v.viz_type, v) for v in viz_types_list
|
||||
|
|
|
@ -76,3 +76,5 @@ Gallery
|
|||
.. image:: _static/img/viz_thumbnails/horizon.png
|
||||
:scale: 25 %
|
||||
|
||||
.. image:: _static/img/viz_thumbnails/mapbox.png
|
||||
:scale: 25 %
|
||||
|
|
Loading…
Reference in New Issue