ScatterPlotGlowOverlay.js 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248
  1. /**
  2. * Licensed to the Apache Software Foundation (ASF) under one
  3. * or more contributor license agreements. See the NOTICE file
  4. * distributed with this work for additional information
  5. * regarding copyright ownership. The ASF licenses this file
  6. * to you under the Apache License, Version 2.0 (the
  7. * "License"); you may not use this file except in compliance
  8. * with the License. You may obtain a copy of the License at
  9. *
  10. * http://www.apache.org/licenses/LICENSE-2.0
  11. *
  12. * Unless required by applicable law or agreed to in writing,
  13. * software distributed under the License is distributed on an
  14. * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
  15. * KIND, either express or implied. See the License for the
  16. * specific language governing permissions and limitations
  17. * under the License.
  18. */
  19. /* eslint-disable react/require-default-props */
  20. import Immutable from 'immutable';
  21. import PropTypes from 'prop-types';
  22. import React from 'react';
  23. import { CanvasOverlay } from 'react-map-gl';
  24. import { kmToPixels, MILES_PER_KM } from './utils/geo';
  25. import roundDecimal from './utils/roundDecimal';
  26. import luminanceFromRGB from './utils/luminanceFromRGB';
  27. import 'mapbox-gl/dist/mapbox-gl.css';
  28. const propTypes = {
  29. aggregation: PropTypes.string,
  30. compositeOperation: PropTypes.string,
  31. dotRadius: PropTypes.number,
  32. lngLatAccessor: PropTypes.func,
  33. locations: PropTypes.instanceOf(Immutable.List).isRequired,
  34. pointRadiusUnit: PropTypes.string,
  35. renderWhileDragging: PropTypes.bool,
  36. rgb: PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.string, PropTypes.number])),
  37. zoom: PropTypes.number
  38. };
  39. const defaultProps = {
  40. // Same as browser default.
  41. compositeOperation: 'source-over',
  42. dotRadius: 4,
  43. lngLatAccessor: location => [location.get(0), location.get(1)],
  44. renderWhileDragging: true
  45. };
  46. const computeClusterLabel = (properties, aggregation) => {
  47. const count = properties.get('point_count');
  48. if (!aggregation) {
  49. return count;
  50. }
  51. if (aggregation === 'sum' || aggregation === 'min' || aggregation === 'max') {
  52. return properties.get(aggregation);
  53. }
  54. const sum = properties.get('sum');
  55. const mean = sum / count;
  56. if (aggregation === 'mean') {
  57. return Math.round(100 * mean) / 100;
  58. }
  59. const squaredSum = properties.get('squaredSum');
  60. const variance = squaredSum / count - (sum / count) ** 2;
  61. if (aggregation === 'var') {
  62. return Math.round(100 * variance) / 100;
  63. }
  64. if (aggregation === 'stdev') {
  65. return Math.round(100 * Math.sqrt(variance)) / 100;
  66. } // fallback to point_count, this really shouldn't happen
  67. return count;
  68. };
  69. class ScatterPlotGlowOverlay extends React.PureComponent {
  70. constructor(props) {
  71. super(props);
  72. this.redraw = this.redraw.bind(this);
  73. }
  74. drawText(ctx, pixel, options = {}) {
  75. const IS_DARK_THRESHOLD = 110;
  76. const {
  77. fontHeight = 0,
  78. label = '',
  79. radius = 0,
  80. rgb = [0, 0, 0],
  81. shadow = false
  82. } = options;
  83. const maxWidth = radius * 1.8;
  84. const luminance = luminanceFromRGB(rgb[1], rgb[2], rgb[3]);
  85. ctx.globalCompositeOperation = 'source-over';
  86. ctx.fillStyle = luminance <= IS_DARK_THRESHOLD ? 'white' : 'black';
  87. ctx.font = fontHeight + "px sans-serif";
  88. ctx.textAlign = 'center';
  89. ctx.textBaseline = 'middle';
  90. if (shadow) {
  91. ctx.shadowBlur = 15;
  92. ctx.shadowColor = luminance <= IS_DARK_THRESHOLD ? 'black' : '';
  93. }
  94. const textWidth = ctx.measureText(label).width;
  95. if (textWidth > maxWidth) {
  96. const scale = fontHeight / textWidth;
  97. ctx.font = scale * maxWidth + "px sans-serif";
  98. }
  99. const {
  100. compositeOperation
  101. } = this.props;
  102. ctx.fillText(label, pixel[0], pixel[1]);
  103. ctx.globalCompositeOperation = compositeOperation;
  104. ctx.shadowBlur = 0;
  105. ctx.shadowColor = '';
  106. } // Modified: https://github.com/uber/react-map-gl/blob/master/overlays/scatterplot.react.js
  107. redraw({
  108. width,
  109. height,
  110. ctx,
  111. isDragging,
  112. project
  113. }) {
  114. const {
  115. aggregation,
  116. compositeOperation,
  117. dotRadius,
  118. lngLatAccessor,
  119. locations,
  120. pointRadiusUnit,
  121. renderWhileDragging,
  122. rgb,
  123. zoom
  124. } = this.props;
  125. const radius = dotRadius;
  126. const clusterLabelMap = [];
  127. locations.forEach((location, i) => {
  128. if (location.get('properties').get('cluster')) {
  129. clusterLabelMap[i] = computeClusterLabel(location.get('properties'), aggregation);
  130. }
  131. }, this);
  132. const maxLabel = Math.max(...clusterLabelMap.filter(v => !Number.isNaN(v)));
  133. ctx.clearRect(0, 0, width, height);
  134. ctx.globalCompositeOperation = compositeOperation;
  135. if ((renderWhileDragging || !isDragging) && locations) {
  136. locations.forEach(function _forEach(location, i) {
  137. const pixel = project(lngLatAccessor(location));
  138. const pixelRounded = [roundDecimal(pixel[0], 1), roundDecimal(pixel[1], 1)];
  139. if (pixelRounded[0] + radius >= 0 && pixelRounded[0] - radius < width && pixelRounded[1] + radius >= 0 && pixelRounded[1] - radius < height) {
  140. ctx.beginPath();
  141. if (location.get('properties').get('cluster')) {
  142. let clusterLabel = clusterLabelMap[i]; // eslint-disable-next-line no-restricted-properties, unicorn/prefer-exponentiation-operator
  143. const scaledRadius = roundDecimal(Math.pow(clusterLabel / maxLabel, 0.5) * radius, 1);
  144. const fontHeight = roundDecimal(scaledRadius * 0.5, 1);
  145. const [x, y] = pixelRounded;
  146. const gradient = ctx.createRadialGradient(x, y, scaledRadius, x, y, 0);
  147. gradient.addColorStop(1, "rgba(" + rgb[1] + ", " + rgb[2] + ", " + rgb[3] + ", 0.8)");
  148. gradient.addColorStop(0, "rgba(" + rgb[1] + ", " + rgb[2] + ", " + rgb[3] + ", 0)");
  149. ctx.arc(pixelRounded[0], pixelRounded[1], scaledRadius, 0, Math.PI * 2);
  150. ctx.fillStyle = gradient;
  151. ctx.fill();
  152. if (Number.isFinite(parseFloat(clusterLabel))) {
  153. if (clusterLabel >= 10000) {
  154. clusterLabel = Math.round(clusterLabel / 1000) + "k";
  155. } else if (clusterLabel >= 1000) {
  156. clusterLabel = Math.round(clusterLabel / 100) / 10 + "k";
  157. }
  158. this.drawText(ctx, pixelRounded, {
  159. fontHeight,
  160. label: clusterLabel,
  161. radius: scaledRadius,
  162. rgb,
  163. shadow: true
  164. });
  165. }
  166. } else {
  167. const defaultRadius = radius / 6;
  168. const radiusProperty = location.get('properties').get('radius');
  169. const pointMetric = location.get('properties').get('metric');
  170. let pointRadius = radiusProperty === null ? defaultRadius : radiusProperty;
  171. let pointLabel;
  172. if (radiusProperty !== null) {
  173. const pointLatitude = lngLatAccessor(location)[1];
  174. if (pointRadiusUnit === 'Kilometers') {
  175. pointLabel = roundDecimal(pointRadius, 2) + "km";
  176. pointRadius = kmToPixels(pointRadius, pointLatitude, zoom);
  177. } else if (pointRadiusUnit === 'Miles') {
  178. pointLabel = roundDecimal(pointRadius, 2) + "mi";
  179. pointRadius = kmToPixels(pointRadius * MILES_PER_KM, pointLatitude, zoom);
  180. }
  181. }
  182. if (pointMetric !== null) {
  183. pointLabel = Number.isFinite(parseFloat(pointMetric)) ? roundDecimal(pointMetric, 2) : pointMetric;
  184. } // Fall back to default points if pointRadius wasn't a numerical column
  185. if (!pointRadius) {
  186. pointRadius = defaultRadius;
  187. }
  188. ctx.arc(pixelRounded[0], pixelRounded[1], roundDecimal(pointRadius, 1), 0, Math.PI * 2);
  189. ctx.fillStyle = "rgb(" + rgb[1] + ", " + rgb[2] + ", " + rgb[3] + ")";
  190. ctx.fill();
  191. if (pointLabel !== undefined) {
  192. this.drawText(ctx, pixelRounded, {
  193. fontHeight: roundDecimal(pointRadius, 1),
  194. label: pointLabel,
  195. radius: pointRadius,
  196. rgb,
  197. shadow: false
  198. });
  199. }
  200. }
  201. }
  202. }, this);
  203. }
  204. }
  205. render() {
  206. return React.createElement(CanvasOverlay, {
  207. redraw: this.redraw
  208. });
  209. }
  210. }
  211. ScatterPlotGlowOverlay.propTypes = propTypes;
  212. ScatterPlotGlowOverlay.defaultProps = defaultProps;
  213. export default ScatterPlotGlowOverlay;