superset/superset-frontend/plugins/legacy-plugin-chart-map-box/src/ScatterPlotGlowOverlay.jsx

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;