javascript

Кластеризация маркеров на карте Google Maps API

  • среда, 2 августа 2017 г. в 03:12:56
https://habrahabr.ru/post/334644/
  • ReactJS
  • JavaScript
  • Google API


Привет, Хабр! Хочу рассказать о моем опыте разработки карты с кластеризованными маркерами на google maps api и React.js. Кластеризация — это группировка близлежащих маркеров, меток, точек в один кластер. Это помогает улучшить UX и отобразить данные визуально понятнее, чем куча наехавших друг на друга точек. Компания, в которой я работаю, создает уникальный продукт для СМИ, это мобильное приложение, смысл которого заключается в съемке фото/видео/стрим материалов и возможности получить отличную компенсацию от СМИ в том случае, если редакция использует ваш материал в публикации. Я занимаюсь разработкой SPA приложения на стеке react/redux для модерации контента, присылаемого пользователями. Недавно передо мной встала задача сделать интерактивную карту на которой можно было бы увидеть местоположение пользователей и отправить им push уведомление, если поблизости происходит интересное событие.

Вот что мне предстояло сделать:



Первое что пришло мне на ум, поискать готовое решение для react.js. Я нашел 2 топовых библиотеки google-map-react и react-google-maps. Они представляют собой обертки над стандартным API Google maps, представленные в виде компонент для react.js. Мой выбор пал на google-map-react потому-что она позволяла использовать в качестве маркера любой JSX элемент, напомню что стандартные средства google maps api позволяют использовать в качестве маркера изображение и svg элемент, в сети есть решения, описывающие хитрую вставку html конструкций в качестве маркера, но google-map-react представляет это из коробки.

Едем дальше, на макете видно что если маркеры находятся близко к друг другу, они объединяются в групповой маркер — это и есть кластеризация. В readme google-map-react я нашел пример кластеризации, но он был реализован с помощью recompose — это утилита, которая создает обертку над function components и higher-order components. Создатели пишут чтобы мы думали что это некий lodash для реакта. Но тем, кто незнаком с recompose врятли сразу будет все понятно, поэтому я адаптировал этот пример и убрал лишнюю зависимость.

Для начала зададим свойства для google-map-react и state компоненты, отрендерим карту с заранее подготовленными маркерами:
(api key получаем здесь)

const MAP = {
  defaultZoom: 8,
  defaultCenter: { lat: 60.814305, lng: 47.051773 },
  options: {
    maxZoom: 19,
  },
};

state = {
  mapOptions: {
    center: MAP.defaultCenter,
    zoom: MAP.defaultZoom,
  },
  clusters: [],
};

//JSX 

<GoogleMapReact
  defaultZoom={MAP.defaultZoom}
  defaultCenter={MAP.defaultCenter}
  options={MAP.options}
  onChange={this.handleMapChange}
  yesIWantToUseGoogleMapApiInternals
  bootstrapURLKeys={{ key: 'yourkey' }} 
>
  {this.state.clusters.map(item => {
    if (item.numPoints === 1) {
      return (
        <Marker
          key={item.id}
          lat={item.points[0].lat}
          lng={item.points[0].lng}
        />
      );
    }

    return (
      <ClusterMarker
        key={item.id}
        lat={item.lat}
        lng={item.lng}
        points={item.points}
      />
    );
  })}
</GoogleMapReact>

Маркеров на карте не будет, так как массив this.state.clusters пустой. Для объединения маркеров в группу используем библиотеку supercluster

Для примера сгенерируем точки с координатами:

const TOTAL_COUNT = 200;

export const susolvkaCoords = { lat: 60.814305, lng: 47.051773 };

export const markersData = [...Array(TOTAL_COUNT)]
  .fill(0) // fill(0) for loose mode
  .map((__, index) => ({
    id: index,
    lat:
      susolvkaCoords.lat +
      0.01 *
        index *
        Math.sin(30 * Math.PI * index / 180) *
        Math.cos(50 * Math.PI * index / 180) +
      Math.sin(5 * index / 180),
    lng:
      susolvkaCoords.lng +
      0.01 *
        index *
        Math.cos(70 + 23 * Math.PI * index / 180) *
        Math.cos(50 * Math.PI * index / 180) +
      Math.sin(5 * index / 180),
  }));

При каждом изменении масштаба/центра карты будем пересчитывать кластеры:

handleMapChange = ({ center, zoom, bounds }) => {
  this.setState(
    {
      mapOptions: {
        center,
        zoom,
        bounds,
      },
    },
    () => {
      this.createClusters(this.props);
    }
  );
};

createClusters = props => {
  this.setState({
    clusters: this.state.mapOptions.bounds
      ? this.getClusters(props).map(({ wx, wy, numPoints, points }) => ({
          lat: wy,
          lng: wx,
          numPoints,
          id: `${numPoints}_${points[0].id}`,
          points,
        }))
      : [],
  });
};

getClusters = () => {
  const clusters = supercluster(markersData, {
    minZoom: 0,
    maxZoom: 16,
    radius: 60,
  });

  return clusters(this.state.mapOptions);
};

В методе getClusters мы скармливаем сгенерированные точки в supercluster, и на выходе получаем кластеры. Таким образом supercluster просто объединил лежащие рядом координаты точек и выдал новую точку со своими координатами и массивом points, где лежат все вошедшие точки.

Демо можно посмотреть здесь
Исходный код примера здесь