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();
}
}