mirror of https://github.com/apache/superset.git
281 lines
8.6 KiB
JavaScript
281 lines
8.6 KiB
JavaScript
/**
|
|
* Licensed to the Apache Software Foundation (ASF) under one
|
|
* or more contributor license agreements. See the NOTICE file
|
|
* distributed with this work for additional information
|
|
* regarding copyright ownership. The ASF licenses this file
|
|
* to you under the Apache License, Version 2.0 (the
|
|
* "License"); you may not use this file except in compliance
|
|
* with the License. You may obtain a copy of the License at
|
|
*
|
|
* http://www.apache.org/licenses/LICENSE-2.0
|
|
*
|
|
* Unless required by applicable law or agreed to in writing,
|
|
* software distributed under the License is distributed on an
|
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
* KIND, either express or implied. See the License for the
|
|
* specific language governing permissions and limitations
|
|
* under the License.
|
|
*/
|
|
/* eslint-disable react/require-default-props */
|
|
import PropTypes from 'prop-types';
|
|
import React from 'react';
|
|
import { CanvasOverlay } from 'react-map-gl';
|
|
import { kmToPixels, MILES_PER_KM } from './utils/geo';
|
|
import roundDecimal from './utils/roundDecimal';
|
|
import luminanceFromRGB from './utils/luminanceFromRGB';
|
|
import 'mapbox-gl/dist/mapbox-gl.css';
|
|
|
|
const propTypes = {
|
|
aggregation: PropTypes.string,
|
|
compositeOperation: PropTypes.string,
|
|
dotRadius: PropTypes.number,
|
|
lngLatAccessor: PropTypes.func,
|
|
locations: PropTypes.arrayOf(PropTypes.object).isRequired,
|
|
pointRadiusUnit: PropTypes.string,
|
|
renderWhileDragging: PropTypes.bool,
|
|
rgb: PropTypes.arrayOf(
|
|
PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
|
),
|
|
zoom: PropTypes.number,
|
|
};
|
|
|
|
const defaultProps = {
|
|
// Same as browser default.
|
|
compositeOperation: 'source-over',
|
|
dotRadius: 4,
|
|
lngLatAccessor: location => [location[0], location[1]],
|
|
renderWhileDragging: true,
|
|
};
|
|
|
|
const computeClusterLabel = (properties, aggregation) => {
|
|
const count = properties.point_count;
|
|
if (!aggregation) {
|
|
return count;
|
|
}
|
|
if (aggregation === 'sum' || aggregation === 'min' || aggregation === 'max') {
|
|
return properties[aggregation];
|
|
}
|
|
const { sum } = properties;
|
|
const mean = sum / count;
|
|
if (aggregation === 'mean') {
|
|
return Math.round(100 * mean) / 100;
|
|
}
|
|
const { squaredSum } = properties;
|
|
const variance = squaredSum / count - (sum / count) ** 2;
|
|
if (aggregation === 'var') {
|
|
return Math.round(100 * variance) / 100;
|
|
}
|
|
if (aggregation === 'stdev') {
|
|
return Math.round(100 * Math.sqrt(variance)) / 100;
|
|
}
|
|
|
|
// fallback to point_count, this really shouldn't happen
|
|
return count;
|
|
};
|
|
|
|
class ScatterPlotGlowOverlay extends React.PureComponent {
|
|
constructor(props) {
|
|
super(props);
|
|
this.redraw = this.redraw.bind(this);
|
|
}
|
|
|
|
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 = luminanceFromRGB(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`;
|
|
}
|
|
|
|
const { compositeOperation } = this.props;
|
|
|
|
ctx.fillText(label, pixel[0], pixel[1]);
|
|
ctx.globalCompositeOperation = compositeOperation;
|
|
ctx.shadowBlur = 0;
|
|
ctx.shadowColor = '';
|
|
}
|
|
|
|
// Modified: https://github.com/uber/react-map-gl/blob/master/overlays/scatterplot.react.js
|
|
redraw({ width, height, ctx, isDragging, project }) {
|
|
const {
|
|
aggregation,
|
|
compositeOperation,
|
|
dotRadius,
|
|
lngLatAccessor,
|
|
locations,
|
|
pointRadiusUnit,
|
|
renderWhileDragging,
|
|
rgb,
|
|
zoom,
|
|
} = this.props;
|
|
|
|
const radius = dotRadius;
|
|
const clusterLabelMap = [];
|
|
|
|
locations.forEach((location, i) => {
|
|
if (location.properties.cluster) {
|
|
clusterLabelMap[i] = computeClusterLabel(
|
|
location.properties,
|
|
aggregation,
|
|
);
|
|
}
|
|
}, this);
|
|
|
|
const maxLabel = Math.max(...clusterLabelMap.filter(v => !Number.isNaN(v)));
|
|
|
|
ctx.clearRect(0, 0, width, height);
|
|
ctx.globalCompositeOperation = compositeOperation;
|
|
|
|
if ((renderWhileDragging || !isDragging) && locations) {
|
|
locations.forEach(function _forEach(location, i) {
|
|
const pixel = project(lngLatAccessor(location));
|
|
const pixelRounded = [
|
|
roundDecimal(pixel[0], 1),
|
|
roundDecimal(pixel[1], 1),
|
|
];
|
|
|
|
if (
|
|
pixelRounded[0] + radius >= 0 &&
|
|
pixelRounded[0] - radius < width &&
|
|
pixelRounded[1] + radius >= 0 &&
|
|
pixelRounded[1] - radius < height
|
|
) {
|
|
ctx.beginPath();
|
|
if (location.properties.cluster) {
|
|
let clusterLabel = clusterLabelMap[i];
|
|
const scaledRadius = roundDecimal(
|
|
(clusterLabel / maxLabel) ** 0.5 * radius,
|
|
1,
|
|
);
|
|
const fontHeight = roundDecimal(scaledRadius * 0.5, 1);
|
|
const [x, y] = pixelRounded;
|
|
const gradient = ctx.createRadialGradient(
|
|
x,
|
|
y,
|
|
scaledRadius,
|
|
x,
|
|
y,
|
|
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 (Number.isFinite(parseFloat(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.properties.radius;
|
|
const pointMetric = location.properties.metric;
|
|
let pointRadius =
|
|
radiusProperty === null ? defaultRadius : radiusProperty;
|
|
let pointLabel;
|
|
|
|
if (radiusProperty !== null) {
|
|
const pointLatitude = lngLatAccessor(location)[1];
|
|
if (pointRadiusUnit === 'Kilometers') {
|
|
pointLabel = `${roundDecimal(pointRadius, 2)}km`;
|
|
pointRadius = kmToPixels(pointRadius, pointLatitude, zoom);
|
|
} else if (pointRadiusUnit === 'Miles') {
|
|
pointLabel = `${roundDecimal(pointRadius, 2)}mi`;
|
|
pointRadius = kmToPixels(
|
|
pointRadius * MILES_PER_KM,
|
|
pointLatitude,
|
|
zoom,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (pointMetric !== null) {
|
|
pointLabel = Number.isFinite(parseFloat(pointMetric))
|
|
? roundDecimal(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],
|
|
roundDecimal(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: roundDecimal(pointRadius, 1),
|
|
label: pointLabel,
|
|
radius: pointRadius,
|
|
rgb,
|
|
shadow: false,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}, this);
|
|
}
|
|
}
|
|
|
|
render() {
|
|
return <CanvasOverlay redraw={this.redraw} />;
|
|
}
|
|
}
|
|
|
|
ScatterPlotGlowOverlay.propTypes = propTypes;
|
|
ScatterPlotGlowOverlay.defaultProps = defaultProps;
|
|
|
|
export default ScatterPlotGlowOverlay;
|