見出し画像

google-map-reactとuse-superclusterを使ったGoogleマップのクラスタリング実装

React でGoogleマップのクラスタリングを実装しようとしたら、日本語記事が全然なくて苦労したので記事を書きました。英語読める人は素直にこちらの記事を読んで下さい。

デモ

画像1

導入

クラスタリングの前段となるGoogleマップ自体の描画は他の記事に譲りますが、ライブラリとしては google-map-react or @react-google-maps/api がメジャーなようです。
本記事ではgoogle-map-react を使用しています。
適当なマーカーを表示させてスタート。

import GoogleMapReact from "google-map-react";
import styled from "@emotion/styled";

const MapCanvasElement = styled.div`
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
`;

const MarkerElement = styled.div`
    background: orange;
    color: white;
    padding: 15px 10px;
    display: inline-flex;
    border-radius: 100%;
    transform: translate(-50%, -50%);
`;

export const MapComponent = () => {
const Marker = ({ text }) => <MarkerElement>{text}</MarkerElement>;
    return (
      <MapCanvasElement>
        <GoogleMapReact
          bootstrapURLKeys={{ key: "キー入れる" }}
          center={{ lat: 35, lng: 135 }}
          zoom={5}
        >
          <Marker lat={35.8} lng={135} text={"test1"} />
          <Marker lat={35.5} lng={140} text={"test2"} />
          <Marker lat={34} lng={134} text={"test3"} />
        </GoogleMapReact>
      </MapCanvasElement>
    );
};

画像2

use-superclusterとGeoJSON

今回はクラスタリング機能の実装にuse-supercluster を使用しました。これは、クラスタリング用ライブラリ supercluster のラッパーライブラリで、フック機能により React で使いやすくしてくれています。

use-superclusterは、マーカーの情報をGeoJSONのFeatureオブジェクトの配列で渡す必要があります。GeoJSONはざっくり言うと地理情報を表現するためのjsonフォーマット形式で、こちらの記事が詳しいです。

// 1マーカー表示に最低限必要な項目
{
   "type": "Feature",
   "properties": {
     "cluster": false
   },
   "geometry": {
      "type": "Point", 
      "coordinates": [135, 35.8] //経度、緯度の順。注意。
   }
}

マーカーのテキストやidといった追加情報を持たせたいときは、propertiesの中に定義します。

test1~3のマーカー情報を持った配列を定義します。

const MarkerArray = [
    {
      type: "Feature",
      properties: {
        cluster: false,
        id: 1,
        label: "test1",
      },
      geometry: {
        type: "Point",
        coordinates: [135, 35.8],
      },
    },
    {
      type: "Feature",
      properties: {
        cluster: false,
        id: 2,
        label: "test2",
      },
      geometry: {
        type: "Point",
        coordinates: [140, 35.5],
      },
    },
    {
      type: "Feature",
      properties: {
        cluster: false,
        id: 3,
        label: "test3",
      },
      geometry: {
        type: "Point",
        coordinates: [134, 34],
      },
    },
];

use-superclusterの導入

useSuperclusterを定義します。

export const MapComponent = () => {
    const [bounds, setBounds] = useState<any | null>(null);
    const [zoom, setZoom] = useState<number>(5);
    const MarkerArray = (略)
    
    const { clusters, supercluster } = useSupercluster({
      points: MarkerArray,
      bounds: bounds,
      zoom: zoom,
      options: { radius: 50, maxZoom: 20 },
    });
    
    (中略)
    <GoogleMapReact
        bootstrapURLKeys={{ key: "キー入れる" }}
        center={{ lat: 35, lng: 135 }}
        zoom={zoom}
        onChange={({ zoom, bounds }) => {
          setZoom(zoom);
          setBounds([
            bounds.nw.lng,
            bounds.se.lat,
            bounds.se.lng,
            bounds.nw.lat,
          ]);
        }}
    >

pointsにマーカーの情報、boundsにマップの境界値、zoomに拡大率を渡します。
GoogleMapReactコンポーネントのonChangeのコールバックとしてboundsとzoomを取得できるので、useStateに保存します。
optionsに使える項目は superclusterのOptions に記載があります。

useSuperclusterの返り値のうち、clustersは、クラスターまたはマーカーのGeoJSON Featureオブジェクトの配列です。
例えばtest1とtest3マーカーがまとまってクラスターになり、test2がまとまってない(マーカー)時、clustersの中身は以下のようになります。

[
    {
      "type": "Feature",
      "properties": {
        "cluster": true,
        "cluster_id": 8, //idは自動で振られる
        "point_count": 2, //まとまったマーカーの数
        "point_count_abbreviated": 2
      },
      "geometry": {
        "type": "Point",
        "coordinates": [34.9, 134.5]
      }
    },
    {
      "type": "Feature",
      "properties": {
        "cluster": false,
        "label": "test2"
      },
      "geometry": {
        "type": "Point",
        "coordinates": [35.5, 140]
      }
    }
]

クラスター、マーカの表示

clustersにクラスター(マーカー)の情報が入ってくるので、クラスター用のスタイルを作成して、後はよしなに展開してあげます。

const ClusterElement = styled.div`
    background: green;
    min-width: 50px;
    min-height: 50px;
    display: inline-flex;
    border-radius: 100%;
    transform: translate(-50%, -50%);
`;


const Marker = ({ text }) => <MarkerElement>{text}</MarkerElement>;
const Cluster = () => <ClusterElement />;

return (
    <MapCanvasElement>
      <GoogleMapReact
       (略)
      >
        {clusters.map((cluster) => {
          const [longitude, latitude] = cluster.geometry.coordinates;
          const { cluster: isCluster } = cluster.properties;
          if (isCluster) {
            return (
              <Cluster
                key={`cluster-${cluster.id}`}
                lat={latitude}
                lng={longitude}
              />
            );
          }
          return (
            <Marker
              key={cluster.properties.id}
              lat={latitude}
              lng={longitude}
              text={cluster.properties.label}
            />
          );
        })}
      </GoogleMapReact>
    </MapCanvasElement>

こうなっていれば成功です。

画像4

Optional:クラスターをクリックしてズームする

このままでもクラスタリングとしては十分ですが、クラスターをクリックするとズームインする機能を入れてみます。
useSuperclusterの返り値であるsuperclusterは superclusterのメソッド にアクセスできるので、getClusterExpansionZoomを呼び出します。
getClusterExpansionZoomで計算された拡大率をsetZoomに渡してマップを拡大します。

if (isCluster) {
    return (
      <Cluster
        key={`cluster-${cluster.id}`}
        lat={latitude}
        lng={longitude}
        onClick={() => {
          const expansionZoom = Math.min(
            supercluster.getClusterExpansionZoom(cluster.id),
            20
          );
          setZoom(expansionZoom);
        }}
      />
    );
}

画像4

ズーム機能も含めた全体のソースはこちらです。

import GoogleMapReact from "google-map-react";
import styled from "@emotion/styled";
import useSupercluster from "use-supercluster";
import React, { useState } from "react";

const MapCanvasElement = styled.div`
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
`;

const MarkerElement = styled.div`
    background: orange;
    color: white;
    padding: 15px 10px;
    display: inline-flex;
    border-radius: 100%;
    transform: translate(-50%, -50%);
`;

const ClusterElement = styled.div`
    background: green;
    min-width: 50px;
    min-height: 50px;
    display: inline-flex;
    border-radius: 100%;
    transform: translate(-50%, -50%);
`;

export const MapComponent = () => {
    const [bounds, setBounds] = useState<any | null>(null);
    const [zoom, setZoom] = useState<number>(5);
    const MarkerArray = [
      {
        type: "Feature",
        properties: {
          cluster: false,
          id: 1,
          label: "test1",
        },
        geometry: {
          type: "Point",
          coordinates: [135, 35.8],
        },
      },
      {
        type: "Feature",
        properties: {
          cluster: false,
          id: 2,
          label: "test2",
        },
        geometry: {
          type: "Point",
          coordinates: [140, 35.5],
        },
      },
      {
        type: "Feature",
        properties: {
          cluster: false,
          id: 3,
          label: "test3",
        },
        geometry: {
          type: "Point",
          coordinates: [134, 34],
        },
      },
    ];
    
    const { clusters, supercluster } = useSupercluster({
      points: MarkerArray,
      bounds: bounds,
      zoom: zoom,
      options: { radius: 100, maxZoom: 20 },
    });
    const Marker = ({ text }) => <MarkerElement>{text}</MarkerElement>;
    const Cluster = () => <ClusterElement />;
    
    return (
      <MapCanvasElement>
        <GoogleMapReact
          bootstrapURLKeys={{ key: "キー入れる" }}
          center={{ lat: 35, lng: 135 }}
          zoom={zoom}
          yesIWantToUseGoogleMapApiInternals
          onChange={({ zoom, bounds }) => {
            setZoom(zoom);
            setBounds([
              bounds.nw.lng,
              bounds.se.lat,
              bounds.se.lng,
              bounds.nw.lat,
            ]);
          }}
        >
          {clusters.map((cluster) => {
            const [longitude, latitude] = cluster.geometry.coordinates;
            const { cluster: isCluster } = cluster.properties;
            if (isCluster) {
              return (
                <Cluster
                  key={`cluster-${cluster.id}`}
                  lat={latitude}
                  lng={longitude}
                  onClick={() => {
                    const expansionZoom = Math.min(
                      supercluster.getClusterExpansionZoom(cluster.id),
                      20
                    );
                    setZoom(expansionZoom);
                  }}
                />
              );
            }
            return (
              <Marker
                key={cluster.properties.id}
                lat={latitude}
                lng={longitude}
                text={cluster.properties.label}
              />
            );
          })}
        </GoogleMapReact>
      </MapCanvasElement>
    );
};

おわりに

冒頭のデモは、今回解説したクラスタリング機能を使った実際のサービス動画です。

どこまで行けるマップ(仮)
出発地の駅と所要時間(n分以内)を指定して、行ける範囲を検索できます。

デモ目的歓迎なので、ぜひ見に来て下さい!

この記事が気に入ったらサポートをしてみませんか?