blob: b5050f10fffba6121a06f1d8a820d77d035b7158 [file] [log] [blame]
/* eslint-disable no-param-reassign */
/* eslint-disable react/no-multi-comp */
import d3 from 'd3';
import React from 'react';
import PropTypes from 'prop-types';
import ReactDOM from 'react-dom';
import MapGL from 'react-map-gl';
import Immutable from 'immutable';
import supercluster from 'supercluster';
import ViewportMercator from 'viewport-mercator-project';
import {
kmToPixels,
rgbLuminance,
isNumeric,
MILES_PER_KM,
DEFAULT_LONGITUDE,
DEFAULT_LATITUDE,
DEFAULT_ZOOM,
} from '../utils/common';
import './mapbox.css';
const NOOP = () => {};
class ScatterPlotGlowOverlay extends React.Component {
componentDidMount() {
this.redraw();
}
componentDidUpdate() {
this.redraw();
}
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;
const clusterLabelMap = [];
let maxLabel = -1;
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)) {
if (clusterLabel >= 10000) {
clusterLabel = Math.round(clusterLabel / 1000) + 'k';
} else if (clusterLabel >= 1000) {
clusterLabel = (Math.round(clusterLabel / 100) / 10) + 'k';
}
this.drawText(ctx, pixelRounded, {
fontHeight,
label: clusterLabel,
radius: scaledRadius,
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) {
const pointLatitude = props.lngLatAccessor(location)[1];
if (props.pointRadiusUnit === 'Kilometers') {
pointLabel = d3.round(pointRadius, 2) + 'km';
pointRadius = kmToPixels(pointRadius, pointLatitude, props.zoom);
} else if (props.pointRadiusUnit === 'Miles') {
pointLabel = d3.round(pointRadius, 2) + 'mi';
pointRadius = kmToPixels(pointRadius * MILES_PER_KM, pointLatitude, 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,
shadow: false,
});
}
}
}
}, this);
}
ctx.restore();
}
render() {
let width = 0;
let height = 0;
if (this.context.viewport) {
width = this.context.viewport.width;
height = this.context.viewport.height;
}
const { globalOpacity } = this.props;
const pixelRatio = window.devicePixelRatio || 1;
return (
React.createElement('canvas', {
ref: 'overlay',
width: width * pixelRatio,
height: height * pixelRatio,
style: {
width: `${width}px`,
height: `${height}px`,
position: 'absolute',
pointerEvents: 'none',
opacity: globalOpacity,
left: 0,
top: 0,
},
})
);
}
}
ScatterPlotGlowOverlay.propTypes = {
locations: PropTypes.instanceOf(Immutable.List).isRequired,
lngLatAccessor: PropTypes.func,
renderWhileDragging: PropTypes.bool,
globalOpacity: PropTypes.number,
dotRadius: PropTypes.number,
dotFill: PropTypes.string,
compositeOperation: PropTypes.string,
};
ScatterPlotGlowOverlay.defaultProps = {
lngLatAccessor: location => [location.get(0), location.get(1)],
renderWhileDragging: true,
dotRadius: 4,
dotFill: '#1FBAD6',
globalOpacity: 1,
// Same as browser default.
compositeOperation: 'source-over',
};
ScatterPlotGlowOverlay.contextTypes = {
viewport: PropTypes.object,
isDragging: PropTypes.bool,
};
class MapboxViz extends React.Component {
constructor(props) {
super(props);
const longitude = this.props.viewportLongitude || DEFAULT_LONGITUDE;
const latitude = this.props.viewportLatitude || DEFAULT_LATITUDE;
this.state = {
viewport: {
longitude,
latitude,
zoom: this.props.viewportZoom || DEFAULT_ZOOM,
startDragLngLat: [longitude, latitude],
},
};
this.onViewportChange = this.onViewportChange.bind(this);
}
onViewportChange(viewport) {
this.setState({ viewport });
this.props.setControlValue('viewport_longitude', viewport.longitude);
this.props.setControlValue('viewport_latitude', viewport.latitude);
this.props.setControlValue('viewport_zoom', viewport.zoom);
}
render() {
const mercator = ViewportMercator({
width: this.props.sliceWidth,
height: this.props.sliceHeight,
longitude: this.state.viewport.longitude,
latitude: this.state.viewport.latitude,
zoom: this.state.viewport.zoom,
});
const topLeft = mercator.unproject([0, 0]);
const bottomRight = mercator.unproject([this.props.sliceWidth, this.props.sliceHeight]);
const bbox = [topLeft[0], bottomRight[1], bottomRight[0], topLeft[1]];
const clusters = this.props.clusterer.getClusters(bbox, Math.round(this.state.viewport.zoom));
const isDragging = this.state.viewport.isDragging === undefined ? false :
this.state.viewport.isDragging;
return (
<MapGL
{...this.state.viewport}
mapStyle={this.props.mapStyle}
width={this.props.sliceWidth}
height={this.props.sliceHeight}
mapboxApiAccessToken={this.props.mapboxApiKey}
onViewportChange={this.onViewportChange}
>
<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>
);
}
}
MapboxViz.propTypes = {
aggregatorName: PropTypes.string,
clusterer: PropTypes.object,
setControlValue: PropTypes.func,
globalOpacity: PropTypes.number,
mapStyle: PropTypes.string,
mapboxApiKey: PropTypes.string,
pointRadius: PropTypes.number,
pointRadiusUnit: PropTypes.string,
renderWhileDragging: PropTypes.bool,
rgb: PropTypes.array,
sliceHeight: PropTypes.number,
sliceWidth: PropTypes.number,
viewportLatitude: PropTypes.number,
viewportLongitude: PropTypes.number,
viewportZoom: PropTypes.number,
};
function mapbox(slice, json, setControlValue) {
const div = d3.select(slice.selector);
const DEFAULT_POINT_RADIUS = 60;
const DEFAULT_MAX_ZOOM = 16;
// Validate mapbox color
const rgb = /^rgb\((\d{1,3}),\s*(\d{1,3}),\s*(\d{1,3})\)$/.exec(json.data.color);
if (rgb === null) {
slice.error('Color field must be of form \'rgb(%d, %d, %d)\'');
return;
}
const aggName = json.data.aggregatorName;
let reducer;
if (aggName === 'sum' || !json.data.customMetric) {
reducer = function (a, b) {
return a + b;
};
} else if (aggName === 'min') {
reducer = Math.min;
} else if (aggName === 'max') {
reducer = Math.max;
} else {
reducer = function (a, b) {
if (a instanceof Array) {
if (b instanceof Array) {
return a.concat(b);
}
a.push(b);
return a;
}
if (b instanceof Array) {
b.push(a);
return b;
}
return [a, b];
};
}
const clusterer = supercluster({
radius: json.data.clusteringRadius,
maxZoom: DEFAULT_MAX_ZOOM,
metricKey: 'metric',
metricReducer: reducer,
});
clusterer.load(json.data.geoJSON.features);
div.selectAll('*').remove();
ReactDOM.render(
<MapboxViz
{...json.data}
rgb={rgb}
sliceHeight={slice.height()}
sliceWidth={slice.width()}
clusterer={clusterer}
pointRadius={DEFAULT_POINT_RADIUS}
aggregatorName={aggName}
setControlValue={setControlValue || NOOP}
/>,
div.node(),
);
}
module.exports = mapbox;