mirror of
https://github.com/apache/superset.git
synced 2024-09-19 20:19:37 -04:00
57ebb2bacf
* 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
337 lines
11 KiB
JavaScript
337 lines
11 KiB
JavaScript
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;
|