Привет, Хабр! Хочу рассказать о моем опыте разработки карты с кластеризованными маркерами на 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, где лежат все вошедшие точки.

Демо можно посмотреть здесь
Исходный код примера здесь
Поделиться с друзьями
-->

Комментарии (10)


  1. Avdeev
    01.08.2017 17:05

    Отличный туториал, спасибо за проделанную работу!


    1. Tim152
      01.08.2017 17:11

      спасибо :)


  1. SergeyVoyteshonok
    01.08.2017 17:32

    Я занимаюсь разработкой SPA приложения на стеке react/redux для модерации контента, присылаемого пользователями.


    С реактом понятно, а где redux? Чего это у вас напрямую работа со стейтом?


  1. Tim152
    01.08.2017 17:52

    Так это и не SPA приложение :) надеюсь вы поняли иронию.
    Я не сторонник хранить «все» в redux, то что можно сделать через локальный стейт, я делаю через state, в продакшене вместо markersData, я прокидываю координаты из родительского контейнера в props, которые приходят из redux


    1. SergeyVoyteshonok
      01.08.2017 18:07

      Ну, на демке вполне себе SPA )). Мне просто интересно, большинство людей, когда используют сложные компоненты типа картографических фреймворков в связке react/redux, забивают на принципы redux.


      1. raveclassic
        01.08.2017 22:32

        Просто redux — не панацея, и применять его нужно аккуратно, там где нужны транзакционные апдейты app-wide состояния. Все остальное прекрасно уживается в local state.


      1. bano-notit
        02.08.2017 01:32

        Стоп стоп стоп… То есть вы хотите, чтобы я, при использовании редакса, все состояния выносил в редакс? Ну это бред! По сути редакс нужен только для каких-то глобальных вещей, типа текущего url, не знаю, состояния рендеринга или логина. Зачем всё приложение уведомлять о том, что на какой-то карте, которая к приложению по сути может вообще не иметь отношения, изменились какие-то маркеры? Конечно если это какая-нибудь тактическая игра.


        1. SergeyVoyteshonok
          02.08.2017 09:14

          Нет, я не говорю, что все должно быть в redux. Просто я вот занимаюсь ГИСами и у меня то, что отображается на карте и есть основная бизнес-логика, а не «изменились какие-то маркеры». Вот мне и интересно было, когда автор сказал про стек react/redux, как он работает со сложными картографическими компонентами, с которыми все общение обычно построено на функциях и событиях, и следование redux при работе с этими компонентами рождает гигантский оверхед. Мои исследовании в итоге привели меня к тому же варианту, который описал автор — чистый react без redux/mobx.


  1. expeon
    02.08.2017 14:12

    А какое у вас требование по количеству одновременно отображаемых маркеров было?


    1. Tim152
      02.08.2017 14:23

      Требований по количеству не было, если точки уже есть на фронте, то гугл карта вполне шустро работает с большим количеством маркеров, пинговать начинает сервер, тк ему эти точки нужно на фронт прислать, в итоге мы пришли к следующему взаимодействию: при изменении координат видимой области карты, фронт отсылает центр и радиус в котором необходимо искать пользователей, а бек возвращает найденных с сортировкой к центру, в итоге работает быстро и грузит только то что надо