<reponame>zer0and1/keplerGl<filename>src/layers/icon-layer/icon-layer.js<gh_stars>0// Copyright (c) 2020 Uber Technologies, Inc.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.

import window from 'global/window';
import {BrushingExtension} from '@deck.gl/extensions';

import {hexToRgb} from 'utils/color-utils';
import SvgIconLayer from 'deckgl-layers/svg-icon-layer/svg-icon-layer';
import IconLayerIcon from './icon-layer-icon';
import {ICON_FIELDS, CLOUDFRONT} from 'constants/default-settings';
import IconInfoModalFactory from './icon-info-modal';
import Layer from '../base-layer';
import {getTextOffsetByRadius, formatTextLabelData} from '../layer-text-label';

const brushingExtension = new BrushingExtension();

export const SVG_ICON_URL = `${CLOUDFRONT}/icons/svg-icons.json`;

export const iconPosAccessor = ({lat, lng}) => d => [d.data[lng.fieldIdx], d.data[lat.fieldIdx]];
export const iconAccessor = ({icon}) => d => d.data[icon.fieldIdx];

export const iconRequiredColumns = ['lat', 'lng', 'icon'];

export const pointVisConfigs = {
  radius: 'radius',
  fixedRadius: 'fixedRadius',
  opacity: 'opacity',
  colorRange: 'colorRange',
  radiusRange: 'radiusRange'
};

function flatterIconPositions(icon) {
  // had to flip y, since @luma modal has changed
  return icon.mesh.cells.reduce((prev, cell) => {
    cell.forEach(p => {
      prev.push(
        ...[icon.mesh.positions[p][0], -icon.mesh.positions[p][1], icon.mesh.positions[p][2]]
      );
    });
    return prev;
  }, []);
}

export default class IconLayer extends Layer {
  constructor(props = {}) {
    super(props);

    this.registerVisConfig(pointVisConfigs);
    this.getPositionAccessor = () => iconPosAccessor(this.config.columns);
    this.getIconAccessor = () => iconAccessor(this.config.columns);

    // prepare layer info modal
    this._layerInfoModal = IconInfoModalFactory();
    this.iconGeometry = props.iconGeometry || null;
    this.getSvgIcons();
  }

  get type() {
    return 'icon';
  }

  get requiredLayerColumns() {
    return iconRequiredColumns;
  }

  get columnPairs() {
    return this.defaultPointColumnPairs;
  }

  get layerIcon() {
    return IconLayerIcon;
  }

  get visualChannels() {
    return {
      ...super.visualChannels,
      size: {
        ...super.visualChannels.size,
        range: 'radiusRange',
        property: 'radius',
        channelScaleType: 'radius'
      }
    };
  }

  get layerInfoModal() {
    return {
      id: 'iconInfo',
      template: this._layerInfoModal,
      modalProps: {
        title: 'modal.iconInfo.title'
      }
    };
  }

  getSvgIcons() {
    const fetchConfig = {
      method: 'GET',
      mode: 'cors',
      cache: 'no-cache'
    };

    if (window.fetch) {
      window
        .fetch(SVG_ICON_URL, fetchConfig)
        .then(response => response.json())
        .then((parsed = {}) => {
          const {svgIcons = []} = parsed;
          this.iconGeometry = svgIcons.reduce(
            (accu, curr) => ({
              ...accu,
              [curr.id]: flatterIconPositions(curr)
            }),
            {}
          );

          this._layerInfoModal = IconInfoModalFactory(svgIcons);
        });
    }
  }

  static findDefaultLayerProps({fieldPairs = [], fields = []}) {
    const notFound = {props: []};
    if (!fieldPairs.length || !fields.length) {
      return notFound;
    }

    const iconFields = fields.filter(({name}) =>
      name
        .replace(/[_,.]+/g, ' ')
        .trim()
        .split(' ')
        .some(seg => ICON_FIELDS.icon.some(t => t.includes(seg)))
    );

    if (!iconFields.length) {
      return notFound;
    }

    // create icon layers for first point pair
    const ptPair = fieldPairs[0];

    const props = iconFields.map(iconField => ({
      label: iconField.name.replace(/[_,.]+/g, ' ').trim(),
      columns: {
        lat: ptPair.pair.lat,
        lng: ptPair.pair.lng,
        icon: {
          value: iconField.name,
          fieldIdx: iconField.tableFieldIndex - 1
        }
      },
      isVisible: true
    }));

    return {props};
  }

  calculateDataAttribute({allData, filteredIndex}, getPosition) {
    const getIcon = this.getIconAccessor();
    const data = [];

    for (let i = 0; i < filteredIndex.length; i++) {
      const index = filteredIndex[i];
      const pos = getPosition({data: allData[index]});
      const icon = getIcon({data: allData[index]});

      // if doesn't have point lat or lng, do not add the point
      // deck.gl can't handle position = null
      if (pos.every(Number.isFinite) && typeof icon === 'string') {
        data.push({
          index,
          icon,
          data: allData[index]
        });
      }
    }

    return data;
  }

  formatLayerData(datasets, oldLayerData, opt = {}) {
    const {
      colorScale,
      colorDomain,
      colorField,
      color,
      sizeField,
      sizeScale,
      sizeDomain,
      textLabel,
      visConfig: {radiusRange, colorRange}
    } = this.config;
    const getPosition = this.getPositionAccessor();

    const {gpuFilter} = datasets[this.config.dataId];
    const {data, triggerChanged} = this.updateData(datasets, oldLayerData);

    // point color
    const cScale =
      colorField &&
      this.getVisChannelScale(colorScale, colorDomain, colorRange.colors.map(hexToRgb));

    // point radius
    const rScale = sizeField && this.getVisChannelScale(sizeScale, sizeDomain, radiusRange, 0);

    const getRadius = rScale ? d => this.getEncodedChannelValue(rScale, d.data, sizeField) : 1;

    const getFillColor = cScale
      ? d => this.getEncodedChannelValue(cScale, d.data, colorField)
      : color;

    // get all distinct characters in the text labels
    const textLabels = formatTextLabelData({
      textLabel,
      triggerChanged,
      oldLayerData,
      data
    });

    return {
      data,
      getPosition,
      getFillColor,
      getFilterValue: gpuFilter.filterValueAccessor(),
      getRadius,
      textLabels
    };
  }

  updateLayerMeta(allData, getPosition) {
    const bounds = this.getPointsBounds(allData, d => getPosition({data: d}));
    this.updateMeta({bounds});
  }

  renderLayer(opts) {
    const {data, gpuFilter, objectHovered, mapState, interactionConfig} = opts;

    const radiusScale = this.getRadiusScaleByZoom(mapState);

    const layerProps = {
      radiusScale,
      ...(this.config.visConfig.fixedRadius ? {} : {radiusMaxPixels: 500})
    };

    const updateTriggers = {
      getFilterValue: gpuFilter.filterValueUpdateTriggers,
      getRadius: {
        sizeField: this.config.colorField,
        radiusRange: this.config.visConfig.radiusRange,
        sizeScale: this.config.sizeScale
      },
      getFillColor: {
        color: this.config.color,
        colorField: this.config.colorField,
        colorRange: this.config.visConfig.colorRange,
        colorScale: this.config.colorScale
      }
    };

    const defaultLayerProps = this.getDefaultDeckLayerProps(opts);
    const brushingProps = this.getBrushingExtensionProps(interactionConfig);
    const getPixelOffset = getTextOffsetByRadius(radiusScale, data.getRadius, mapState);
    const extensions = [...defaultLayerProps.extensions, brushingExtension];

    // shared Props between layer and label layer
    const sharedProps = {
      getFilterValue: data.getFilterValue,
      extensions,
      filterRange: defaultLayerProps.filterRange,
      ...brushingProps
    };

    const labelLayers = [
      ...this.renderTextLabelLayer(
        {
          getPosition: data.getPosition,
          sharedProps,
          getPixelOffset,
          updateTriggers
        },
        opts
      )
    ];

    return !this.iconGeometry
      ? []
      : [
          new SvgIconLayer({
            ...defaultLayerProps,
            ...brushingProps,
            ...layerProps,
            ...data,
            getIconGeometry: id => this.iconGeometry[id],

            // update triggers
            updateTriggers,
            extensions
          }),

          ...(this.isLayerHovered(objectHovered)
            ? [
                new SvgIconLayer({
                  ...this.getDefaultHoverLayerProps(),
                  ...layerProps,
                  data: [objectHovered.object],
                  getPosition: data.getPosition,
                  getRadius: data.getRadius,
                  getFillColor: this.config.highlightColor,
                  getIconGeometry: id => this.iconGeometry[id]
                })
              ]
            : []),

          // text label layer
          ...labelLayers
        ];
  }
}
