_map.js

import _ from 'lodash';
import * as gm from '@google/maps';
import * as numeral from 'numeral';
import Utility from './utility';
import Graphics from './graphics';
import Tooltip from './tooltip';
import Text from './text';

/**
 * Map Chart를 생성, 관리하는 클래스
 * <p>ChartJS에서 지원하지 않는 타입의 차트라 직접 만들어줌
 * @alias Map
 */
export default class Map {
  /**
   * @description Map차트에서 사용되는 Google Maps API를 로드하고 필요한 값들을 설정
   * @param  { object } option 차트 생성에 필요한 옵션
   * @param  { object } datasets 차트 생성에 필요한 데이터
   */
  constructor(option, datasets) {
    /**
     * @description 차트 생성에 필요한 데이터
     * @type { object }
     */
    this.data = datasets;

    /**
     * @description 차트의 타입 기본값은 'gps' 이고 추가로 place가 있음
     * @type { string }
     */
    this.dataType = option.dataType || 'gps';

    /**
     * @description 차트 생성에 필요한 옵션
     * @type { object }
     */
    this.option = option;

    const chartDOM = document.createElement('div');
    chartDOM.id = 'hello';
    chartDOM.style.width = '100%';
    chartDOM.style.height = '100%';
    chartDOM.style.position = 'absolute';
    chartDOM.style.zIndex = 1;
    document.getElementById(option.domId).appendChild(chartDOM);

    /**
     * @description PIXI 오브젝트
     * @type { PIXI }
     */
    this.pixi = Utility.makePixi(option);
    const longest = option.rootSize.width > option.rootSize.height ? option.rootSize.width : option.rootSize.height;

    /**
     * @description gps 타입에서 원의 크기
     * @type { number }
     */
    this.radius = longest / (longest / 20);

    /**
     * @description Map 차트에서 사용 될 Tooltip 오브젝트
     * @type { Tooltip }
     */
    this.tooltip = new Tooltip(this.pixi);

    const loadScriptResult = Utility.loadScript(`https://maps.googleapis.com/maps/api/js?key=${option.apiKey}`);
    loadScriptResult.then(() => {
      /**
       * @description Google Maps API 오브젝트
       * @type { object }
      */
      // eslint-disable-next-line no-undef
      this.google = google;
      this.initMap();
    });

    /**
     * @description 데이터들의 값의 평균
     * @type { number }
     */
    this.average = 0;
    for (let i = 0; i < datasets.length; i++) {
      const { value } = datasets[i].data;
      this.average += parseInt(value, 10);
    }
    this.average = Math.round(this.average / datasets.length);


    /**
     * @description Google Maps Api Geocode를 사용하기 위한 오브젝트
     * @type { object }
     */
    this.googleMapsClient = gm.createClient({
      key: option.apiKey,
    });

    /**
     * @description 구글 지도가 Smooth Move를 하고있는지 판단
     * @type { boolean }
     */
    this.smoothing = false;

    /**
     * @description 데이터의 gps 좌표를 가지고 있는 배열
     * @type { array }
     */
    this.location = [];

    /**
     * @description place 타입일 때 현재 지도에 보여지고있는 오브젝트 배열
     * @type { array }
     */
    this.currentShowObjectPlace = [];

    /**
     * @description gps 타입일 때 현재 지도에 보여지고있는 오브젝트 배열
     * @type { array }
     */
    this.currentShowObjectGPS = [];

    /**
     * @description 현재 마우스 피킹되어있는 오브젝트
     * @type { object }
     */
    this.currentPickingObject = undefined;
    this.tooltip.setUnit('명');
  }

  /**
   * Google Maps API 로드가 완료되면 Google Map를 초기화하면서 설정
   * <p> 각종 Google Maps 이벤트들을 등록해줌
   * @memberof Map
   * @instance
   */
  initMap() {
    const { google } = this;
    const center = new google.maps.LatLng(39.305, -76.617);
    const map = new google.maps.Map(document.getElementById('hello'), {
      center,
      zoom: 18,
      disableDefaultUI: true,
    });

    const overlay = new google.maps.OverlayView();
    overlay.draw = () => { };
    overlay.setMap(map);

    google.maps.event.addListener(map, 'click', () => {
      if (!_.isUndefined(this.currentPickingObject)) {
        const zoomLevel = map.getZoom();
        let nextZoomLevel;
        if (zoomLevel < 5) {
          nextZoomLevel = 6;
        } else if (zoomLevel >= 5 && zoomLevel < 11) {
          nextZoomLevel = 12;
        } else if (zoomLevel >= 11 && zoomLevel < 13) {
          nextZoomLevel = 14;
        } else {
          nextZoomLevel = 16;
        }
        this.smoothing = true;
        map.panTo(this.currentPickingObject.latlng);
        this.smoothZoom(map, nextZoomLevel, map.getZoom());
      }
    });
    google.maps.event.addListener(map, 'bounds_changed', () => {
      if (this.dataType === 'place') {
        this.boundsChangedPlace();
      } else {
        this.boundsChangedGPS();
      }
      this.tooltip.hide();
    });

    google.maps.event.addListener(map, 'mousemove', (e) => {
      if (this.dataType === 'place') {
        this.mouseMovePlace(e, map, overlay);
      } else {
        this.mouseMoveGPS(e, map, overlay);
      }
    });

    google.maps.event.addListener(map, 'idle', () => {
      if (this.dataType === 'place') {
        this.idlePlace(map, overlay);
      } else {
        this.idleGPS(map, overlay);
      }
    });

    if (this.dataType === 'place') {
      this.setDataFromPlace(map);
    } else {
      this.setDataFromGPS(map);
    }
  }

  /**
   * gps 타입일 때 좌표 데이터를 정리하고 그 데이터를 바탕으로 지도를 정렬해줌
   * @memberof Map
   * @instance
   */
  setDataFromGPS(map) {
    const { google } = this;
    const bounds = new google.maps.LatLngBounds();

    for (let i = 0; i < this.data.length; i++) {
      const eachData = this.data[i];
      const latlng = new google.maps.LatLng(eachData.data.lat, eachData.data.lng);
      eachData.latlng = latlng;
      eachData.graphics = new Graphics(this.pixi);
      this.location.push(eachData);
      bounds.extend(latlng);
    }

    Utility.arrangePixiZOrder(this.pixi);
    map.fitBounds(bounds);
  }

  /**
   * place 타입일 때 좌표 데이터를 정리하고 그 데이터를 바탕으로 지도를 정렬해줌
   * @memberof Map
   * @instance
   */
  setDataFromPlace(map) {
    const { google } = this;
    const bounds = new google.maps.LatLngBounds();

    const searchedPlaces = localStorage.getItem('searchedPlaces');
    const searchStore = _.isNull(searchedPlaces) ? {} : JSON.parse(searchedPlaces);
    const promiseArray = [];
    for (let i = 0; i < this.data.length; i++) {
      const eachData = this.data[i];
      const { search } = eachData.data;
      if (_.isUndefined(searchStore[search])) {
        promiseArray.push(this.findPlace(search));
      }
    }
    Promise.all(promiseArray).then((response) => {
      this.dataByDepth = {};
      for (let i = 0; i < response.length; i++) {
        const result = response[i].json.results[0];
        const label = response[i].query.address;
        searchStore[label] = _.isUndefined(result) ? 'ZERO_RESULTS' : result;
      }
      for (let i = 0; i < Object.keys(searchStore).length; i++) {
        const eachData = this.data[i];
        const { value } = eachData.data;
        const key = Object.keys(searchStore)[i];
        const data = _.cloneDeep(searchStore[key]);
        if (!_.isEqual(data, 'ZERO_RESULTS')) {
          const address = data.address_components;
          const latlng = new google.maps.LatLng(data.geometry.location.lat, data.geometry.location.lng);

          for (let j = 0; j < address.length; j++) {
            if (!(_.includes(address[j].types, 'postal_code') || _.includes(address[j].types, 'postal_code_suffix') || _.includes(address[j].types, 'premise'))) {
              if (_.isUndefined(this.dataByDepth[address[j].long_name])) {
                this.dataByDepth[address[j].long_name] = {
                  value: 0,
                  location: [],
                  graphics: new Graphics(this.pixi),
                  valueText: new Text(this.pixi, '', {}),
                  labelText: new Text(this.pixi, '', {}),
                };

                this.dataByDepth[address[j].long_name].valueText.setScale(this.radius * 0.027);
                this.dataByDepth[address[j].long_name].labelText.setScale(this.radius * 0.027);
              }
              this.dataByDepth[address[j].long_name].value += value;
              this.dataByDepth[address[j].long_name].location.push(latlng);
            }
          }
          data.value = value;
          this.location.push(data);
          bounds.extend(latlng);
        }
      }
      map.fitBounds(bounds);
      Utility.arrangePixiZOrder(this.pixi);
      localStorage.setItem('searchedPlaces', JSON.stringify(searchStore));
    }).catch((err) => {
      console.log(err);
    });
  }

  /**
   * geocode를 이용해서 주소의 정보를 요청함
   * @memberof Map
   * @param {string} address 주소
   * @instance
   */
  findPlace(address) {
    return new Promise((resolve, reject) => {
      this.googleMapsClient.geocode({
        address,
      }, (err, response) => {
        if (!err) {
          resolve(response);
        } else {
          reject(err);
        }
      });
    });
  }

  /**
   * x,y 좌표가 원안에 있는지 검사
   * @memberof Map
   * @param {number} px 검사할 x 좌표
   * @param {number} py 검사할 y 좌표
   * @param {number} cx 원의 중심 x 좌표
   * @param {number} cy 원의 중심 y 좌표
   * @param {number} r 원의 반지름
   * @return {boolean} result
   * @instance
   */
  isPointInCircle(px, py, cx, cy, r) {
    // get distance between the point and circle's center
    // using the Pythagorean Theorem
    const distX = px - cx;
    const distY = py - cy;
    const distance = Math.sqrt((distX * distX) + (distY * distY));

    // if the distance is less than the circle's
    // radius the point is inside!
    if (distance <= r) {
      return true;
    }
    return false;
  }

  /**
   * x,y 좌표가 사각형안에 있는지 검사
   * @memberof Map
   * @param {number} _px 검사할 x 좌표
   * @param {number} _py 검사할 y 좌표
   * @param {number} _rectX 사각형의 시작 x 좌표
   * @param {number} _rectWidth 사각형의 가로
   * @param {number} _rectHeight 사각형의 세로
   * @return {boolean} result
   * @instance
   */
  isPointInRectangle(_px, _py, _rectX, _rectY, _rectWidth, _rectHeight) {
    return _rectX <= _px && _px <= _rectX + _rectWidth
      && _rectY <= _py && _py <= _rectY + _rectHeight;
  }

  /**
   * gps 타입일 때 mousemove 이벤트 함수
   * @memberof Map
   * @param {object} event 이벤트 오브젝트
   * @param {GoogleMap} map Google Map 오브젝트
   * @param {object} overlay overlay
   * @instance
   */
  mouseMoveGPS(event, map, overlay) {
    let coliisionCount = 0;
    for (let i = 0; i < this.currentShowObjectGPS.length; i++) {
      const { latlng, label } = this.location[this.currentShowObjectGPS[i]];
      const { value } = this.location[this.currentShowObjectGPS[i]].data;
      const proj = overlay.getProjection();
      const p = proj.fromLatLngToContainerPixel(latlng);
      const valueForRadius = value / (10 ** (this.average.toString().length - 1));
      const radius = valueForRadius * map.getZoom();
      const coliision = this.isPointInCircle(event.pixel.x, event.pixel.y, p.x, p.y, radius);
      if (coliision) {
        this.tooltip.setData(label, value);
        this.tooltip.setPosition(p.x, p.y);
        this.tooltip.show();
      } else {
        coliisionCount++;
      }
    }
    if (coliisionCount === this.currentShowObjectGPS.length) {
      this.tooltip.hide();
    }
  }

  /**
   * place 타입일 때 mousemove 이벤트 함수
   * @memberof Map
   * @param {object} _event 이벤트 오브젝트
   * @instance
   */
  mouseMovePlace(_event) {
    let coliisionCount = 0;
    for (let i = 0; i < this.currentShowObjectPlace.length; i++) {
      const {
        point, rectInfo, labelText, graphics, value,
      } = this.currentShowObjectPlace[i];

      const coliision = this.isPointInRectangle(_event.pixel.x, _event.pixel.y, rectInfo.x, rectInfo.y, rectInfo.width, rectInfo.height);
      if (coliision) {
        if (!_.isUndefined(this.currentPickingObject)) {
          this.drawGraphics(
            this.currentPickingObject.graphics,
            this.currentPickingObject.value,
            this.currentPickingObject.rectInfo,
            this.currentPickingObject.point,
          );
        }

        this.currentPickingObject = this.currentShowObjectPlace[i];
        this.drawGraphics(graphics, value, rectInfo, point, {
          outline: true,
        });

        this.tooltip.setData(labelText.object.text, this.dataByDepth[labelText.object.text].value);
        if (rectInfo.x + rectInfo.width < this.option.rootSize.width / 2) {
          this.tooltip.setPosition(rectInfo.x + rectInfo.width, point.y);
        } else {
          this.tooltip.setPosition(point.x, point.y);
        }
        this.tooltip.show();
      } else {
        coliisionCount++;
      }
    }
    if (coliisionCount === this.currentShowObjectPlace.length) {
      if (!_.isUndefined(this.currentPickingObject)) {
        this.drawGraphics(
          this.currentPickingObject.graphics,
          this.currentPickingObject.value,
          this.currentPickingObject.rectInfo,
          this.currentPickingObject.point,
        );
        this.currentPickingObject = undefined;
      }
      this.tooltip.hide();
    }
  }

  /**
   * gps 타입일 때 idle 이벤트 함수
   * @memberof Map
   * @param {GoogleMap} map Google Map 오브젝트
   * @param {object} overlay overlay
   * @instance
   */
  idleGPS(map, overlay) {
    this.currentShowObjectGPS = [];
    const bounds = map.getBounds();
    for (let i = 0; i < this.location.length; i++) {
      const { latlng, graphics } = this.location[i];
      const { value } = this.location[i].data;
      graphics.object.clear();
      if (bounds.contains(latlng)) {
        const proj = overlay.getProjection();
        const p = proj.fromLatLngToContainerPixel(latlng);
        const valueForRadius = value / (10 ** (this.average.toString().length - 1));
        const radius = valueForRadius * map.getZoom();
        this.currentShowObjectGPS.push(i);
        graphics.object.beginFill(0x1F0200, 0.5);
        graphics.object.lineStyle(2, 0x0000FF, 1);
        graphics.object.drawCircle(p.x, p.y, radius);
        graphics.object.endFill();
      }
    }
  }

  /**
   * place 타입일 때 mousemove 이벤트 함수
   * @memberof Map
   * @param {GoogleMap} map Google Map 오브젝트
   * @param {object} overlay overlay
   * @instance
   */
  idlePlace(map, overlay) {
    // this.currentShowObjectPlace = [];
    if (!_.isUndefined(this.currentPickingObject)) {
      this.currentPickingObject.graphics.object.clear();
      delete this.currentPickingObject;
    }

    if (this.smoothing) {
      return;
    }

    const zoomLevel = map.getZoom();
    let index = 0;

    if (zoomLevel < 5) {
      index = 0;
    } else if (zoomLevel >= 5 && zoomLevel < 11) {
      index = 1;
    } else if (zoomLevel >= 11 && zoomLevel < 13) {
      index = 2;
    } else {
      index = 3;
    }

    const bounds = map.getBounds();
    const combinedData = {};
    this.highestValue = 0;
    for (let i = 0; i < this.location.length; i++) {
      const eachData = this.location[i];
      const latlng = new this.google.maps.LatLng(eachData.geometry.location.lat, eachData.geometry.location.lng);
      if (bounds.contains(latlng)) {
        const address = eachData.address_components;
        let postalCodeCount = 0;
        // postal_code 갯수 체크후 삭감
        for (let j = 0; j < address.length; j++) {
          for (let k = 0; k < address[j].types.length; k++) {
            if (address[j].types[k].includes('postal_code')) {
              postalCodeCount += 1;
            }
          }
        }
        const eachIndex = index > address.length - 1 - postalCodeCount ? 0 : address.length - 1 - index - postalCodeCount;
        const label = address[eachIndex].long_name;
        if (!Object.keys(combinedData).includes(label)) {
          combinedData[label] = this.dataByDepth[label];
          if (this.highestValue < combinedData[label].value) {
            this.highestValue = combinedData[label].value;
          }
        }
      }
    }

    const maxBrightnessPercent = 70;
    for (let i = 0; i < Object.keys(combinedData).length; i++) {
      const center = this.getCenterLocation(combinedData[Object.keys(combinedData)[i]].location);
      const centerLatlng = new this.google.maps.LatLng(center.lat, center.lng);
      const proj = overlay.getProjection();
      const point = proj.fromLatLngToContainerPixel(centerLatlng);
      const { value } = combinedData[Object.keys(combinedData)[i]];
      const string = numeral(value).format('0a');
      const { graphics, valueText, labelText } = this.dataByDepth[Object.keys(combinedData)[i]];
      const labelTextMargin = 10;
      const rectInfo = {
        x: point.x,
        y: point.y - this.radius * 1.3 / 2,
        width: 'not yet calculate',
        height: this.radius * 1.3,
      };

      valueText.object.text = string;
      valueText.setAlpha(1);
      valueText.setPosition(point.x, point.y);
      valueText.object.style.fill = 0xffffff;


      labelText.object.text = Object.keys(combinedData)[i];
      labelText.setAlpha(1);
      labelText.setPosition(point.x + this.radius + labelText.object.width / 2 + labelTextMargin, point.y);
      labelText.object.style.fill = 0xffffff;
      rectInfo.width = labelText.object.width + labelTextMargin * 2 + this.radius;

      const brightnessPercent = (this.highestValue - value) / this.highestValue;
      const labelColor = Utility.increaseBrightness('0xF50057', maxBrightnessPercent * brightnessPercent);
      const valueColor = Utility.increaseBrightness('0xFF4081', maxBrightnessPercent * brightnessPercent);

      graphics.object.beginFill(labelColor, 1);
      graphics.object.drawRoundedRect(rectInfo.x, rectInfo.y, rectInfo.width, rectInfo.height, 10);
      graphics.object.endFill();

      graphics.object.beginFill(valueColor, 1);
      graphics.object.drawCircle(point.x, point.y, this.radius);
      graphics.object.endFill();

      this.currentShowObjectPlace.push({
        graphics, valueText, labelText, value, point, rectInfo, latlng: centerLatlng,
      });
    }
  }


  /**
   * 좌표들을 통해서 가운데 지점을 찾아줌
   * @memberof Map
   * @param {array} locations 좌표를 가지고 있는 배열
   * @instance
   * @return {object} center;
   */
  getCenterLocation(locations) {
    const center = {};
    center.lat = (_.minBy(locations, o => o.lat()).lat() + _.maxBy(locations, o => o.lat()).lat()) / 2;
    center.lng = (_.minBy(locations, o => o.lng()).lng() + _.maxBy(locations, o => o.lng()).lng()) / 2;
    return center;
  }


  /**
   * gps 타입일 때 boundsChanged 이벤트 함수
   * @memberof Map
   * @instance
   */
  boundsChangedGPS() {
    for (let i = 0; i < this.currentShowObjectGPS.length; i++) {
      const { graphics } = this.location[this.currentShowObjectGPS[i]];
      graphics.object.clear();
    }
    this.currentShowObjectGPS = [];
  }

  /**
   * place 타입일 때 boundsChanged 이벤트 함수
   * @memberof Map
   * @instance
   */
  boundsChangedPlace() {
    for (let i = 0; i < this.currentShowObjectPlace.length; i++) {
      const { graphics, valueText, labelText } = this.currentShowObjectPlace[i];
      graphics.object.clear();
      valueText.setAlpha(0);
      labelText.setAlpha(0);
    }
    this.currentShowObjectPlace = [];
  }

  /**
   * Google Map을 부드럽게 확대/축소 하는 함수
   * @memberof Map
   * @param {GoogleMap} map Google Map 오브젝트
   * @param {number} max 확대/축소 할 Level 값
   * @param {number} count 현재 Level 값
   * @instance
   */
  smoothZoom(map, max, count) {
    const { google } = this;
    if (count < max) {
      const z = google.maps.event.addListener(map, 'zoom_changed', () => {
        google.maps.event.removeListener(z);
        this.smoothZoom(map, max, count + 1);
      });
      setTimeout(() => { map.setZoom(count); }, 80); // 80ms is what I found to work well on my system -- it might not work well on all systems
    } else {
      this.smoothing = false;
    }
  }

  /**
   * Google Map위에 정보들을 표현해줌
   * @memberof Map
   * @param {Graphics} _graphics Graphics
   * @param {number} _value 해당 데이터의 값
   * @param {object} _rectInfo 사각형 정보
   * @param {object} _point 정보를 그릴 좌표
   * @param {object} _option 사용자가 지정한 옵션
   * @instance
   */
  drawGraphics(_graphics, _value, _rectInfo, _point, _option) {
    if (_.isUndefined(_graphics) || _.isUndefined(_value) || _.isUndefined(_rectInfo) || _.isUndefined(_point)) {
      return;
    }

    const defaultOption = {
      outline: false,
      labelColor: '0xF50057',
      valueColor: '0xFF4081',
    };

    _option = { ...defaultOption, ..._option };

    const maxBrightnessPercent = 70;
    const brightnessPercent = (this.highestValue - _value) / this.highestValue;
    const labelColor = Utility.increaseBrightness(_option.labelColor, maxBrightnessPercent * brightnessPercent);
    const valueColor = Utility.increaseBrightness(_option.valueColor, maxBrightnessPercent * brightnessPercent);

    _graphics.object.clear();
    _graphics.object.beginFill(labelColor, 1);
    if (_option.outline) {
      _graphics.object.lineStyle(3, 0xffffff, 1);
    }
    _graphics.object.drawRoundedRect(_rectInfo.x, _rectInfo.y, _rectInfo.width, _rectInfo.height, 10);
    _graphics.object.endFill();

    _graphics.object.beginFill(valueColor, 1);
    if (_option.outline) {
      _graphics.object.lineStyle(3, 0xffffff, 1);
    }
    _graphics.object.drawCircle(_point.x, _point.y, this.radius);
    _graphics.object.endFill();
  }
}