airbnb/superset

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

Summary

Maintainability
D
3 days
Test Coverage
/**
 * 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 { PureComponent } 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 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;