2024.09.12[Thu]
[シリーズ連載] @googlemaps/react-wrapperで自作マーカーと自作ポリラインを描画する

@googlemaps/react-wrapperでGoogle Map上に五芒星を描画する

    目次

    こんにちは、エンジニアチームのこむたです。

    『[シリーズ連載] @googlemaps/react-wrapperで自作マーカーと自作ポリラインを描画する』の第4弾の記事になります。
    前回の記事はこちら→https://techlab.q-co.jp/articles/151/

    前回は、Google Mapをクリックした2点の間にラインを描画させました。
    今回の記事では前回のラインの描画を応用して、クリックした2点を基準に五芒星を描いてみます。

    完成形はこちらになります。
    2点をクリックすると五芒星の頂点を算出します。さらに、クリックした位置と五芒星の頂点の付近にあるカフェを自動で検索して、カフェ同士をラインで結んで五芒星を描きます。

    これを実現するには、周辺の場所を検索する機能の実装2点から他の五芒星の頂点を算出する処理の実装が必要です。五芒星の頂点の算出には、三角関数などの数学の知識を応用します。
    今回はこれらの実装方法について紹介します。

    周辺の場所を検索する機能を実装

    Google Mapで場所を検索するには、google.maps.places.PlacesServiceクラスnearbySearchを使用します。 まずは、このクラスを使用する準備を進めます。
    実装の手順は以下に沿って進めます。

    • Maps JavaScript APIのロード状況を管理
    • google.maps.places.PlacesServiceクラスを読み込む
    • カフェを検索する機能の実装

    まずは、Maps JavaScript APIがロードされたら自動でgoogle.maps.places.PlacesServiceクラスを使用できるように、ロード状況を管理する処理を書いていきます。

    Maps JavaScript APIのロード状況を管理する

    Map.tsx<Map>にロード状況を保存するStateのhasMapLoadedを準備します。

    // Map.tsx
    
    // ...省略
    
    const Map = () => {
      // 追加
      // Mapのロード状況を保存するState
      const [hasMapLoaded, setHasMapLoaded] = useState(false);
      
      // ...省略
    };
    
    // ...省略

    ロード状況は、<Wapper>のpropsであるcallbackにコールバック関数を渡すとstatusとして取得することができます。
    statusは3つの状態を持っていて、型も提供されています。

    // statusの中身
    export declare enum Status {
        LOADING = "LOADING", // ロード中
        FAILURE = "FAILURE", // ロード失敗
        SUCCESS = "SUCCESS"  // ロード成功
    }
    

    このstatusを使い、状態がSUCCESSであればsetHasMapLoaded(true)に更新するコールバック関数onLoadGoogleMap()を書きます。
    onLoadGoogleMap<Wapper>のpropsであるcallbackに渡すことで、Maps JavaScript APIのロード状況を管理できるようになりました。

    // Map.tsx
    
    // import { Wrapper } from "@googlemaps/react-wrapper"; 削除
    import { Wrapper, Status } from "@googlemaps/react-wrapper"; // 追加 Statusの型をimport
    
    // ...省略
    
    const Map = () => {
      const [hasMapLoaded, setHasMapLoaded] = useState(false);
    	
      // ...省略
    	
      // 追加
      // statusがSUCCESSだったらhasMapLoadedをtrueにする。
      const onLoadGoogleMap = useCallback(
        (status: Status) => {
          if (status !== Status.SUCCESS || hasMapLoaded) return;
          setHasMapLoaded(true);
        },
        [hasMapLoaded]
      );
    	
      return (
        <Wrapper
          apiKey={MAP_API_KEY}
          callback={onLoadGoogleMap} // 追加
        >
          {/* ...省略 */}
        </Wrapper>
      );
    };
    
    
    // ...省略

    google.maps.places.PlacesServiceクラスを読み込む

    google.maps.places.PlacesServiceクラスのインスタンス化には、引数に<div>を渡す必要があります。<Wapper><div>を追加して、useRef()でその<div>を参照できるようにします。

    // Map.tsx
    
    // ...省略
    
    const Map = () => {
      // 追加
      // <div>を参照するためのuseRef
      const placesRef = useRef(null);
    	
      // ...省略
    
      return (
        <Wrapper
          apiKey={MAP_API_KEY}
          callback={onLoadGoogleMap}
        >
          <Content onClickMapCallback={onClickMapCallback}>
          {/* ...省略 */}
          </Content>
    
          {/* 追加 */}
          {/* PlacesServiceクラスをインスタンス化するためのdiv */}
          <div ref={placesRef} />
        </Wrapper>
      );
    };
    
    
    // ...省略

    インスタンスを保存するStateのplacesServiceを準備します。
    useEffect()を使用して、hasMapLoadedtrueであればgoogle.maps.places.PlacesServiceクラスのインスタンスを作り、placesServiceに保存する処理を書きます。
    これで、placesServiceを経由してGoogle Mapで場所を検索するnearbySearchが使えるようになりました。

    // Map.tsx
    
    // ...省略
    
    const Map = () => {
      // 追加
      // PlacesServiceを保存するState
      const [placesService, setPlacesService] = useState<google.maps.places.PlacesService>();
    	
      // ...省略
    	
      // 追加
      // Google Mapのロードが成功したら、
      // placesRef.currentにPlacesServiceのインスタンスを作ってメソッドを使えるようにする。
      useEffect(() => {
        if (!placesRef.current || !hasMapLoaded) return;
        setPlacesService(new google.maps.places.PlacesService(placesRef.current));
      }, [placesRef, hasMapLoaded]);
    
      // ...省略
    };
    
    // ...省略

    カフェを検索する機能の実装

    nearbySearchを使用して、指定した位置から近場のカフェを検索する処理を書きます。

    まずはMaps JavaScript APIの場所検索機能を有効にするため、<Wrapper>のpropsのlibrarieに利用したいAPIを指定します。 今回は場所を検索したいので["places"]を指定します。他のタグについてはこちらにまとめられています。

    // Map.tsx
    
    // ...省略
    
    const Map = () => {
    	
      // ...省略
    
      return (
        <Wrapper
          apiKey={MAP_API_KEY}
          callback={onLoadGoogleMap}
          libraries={["places"]} // 追加
        >
          {/* ...省略 */}
        </Wrapper>
      );
    };
    
    
    // ...省略

    カフェを検索する処理を書いていきます。
    関数nearbySearchCafe()を宣言します。引数には、位置を指定するpositionと検索結果を受け取るコールバック関数callbackを指定します。
    不要に関数が再生成されないように、useCallback()でメモ化もしておきます。

    // Map.tsx
    
    // ...省略
    
    const Map = () => {
      // ...省略
    	
      // 追加
      // 近場のカフェをサーチする。
      const nearbySearchCafe = useCallback(
        (
          position: google.maps.LatLng | google.maps.LatLngLiteral,
          callback: (place: google.maps.places.PlaceResult) => void
        ) => {
          // 処理
      },[]);
    
      // ...省略
    };
    
    
    // ...省略

    placesServiceからnearbySearchを呼び出します。
    nearbySearchは以下の引数を受け取ることができます。

    1. request
      検索に対するクエリを投げることができます。 具体的には、検索する位置や範囲、検索の対象などです。
      PlaceSearchRequest インターフェース
    2. callback
      検索結果を渡すコールバック関数を指定します。callbackからはresultstatuspaginationの3つの引数を受け取ることができます。

      result: google.maps.places.PlaceResult[] | null
      検索結果を受け取ります。 指定した位置から近い順で配列として格納されます。 PlaceResult インターフェース

      status: google.maps.places.PlacesServiceStatus
      検索の完了時の状態を受け取ります。検索が無事に行われたかの確認などに使います。
      PlacesServiceStatus 定数

      pagination: google.maps.places.PlaceSearchPagination
      結果の追加ページを取得するために使用するオブジェクトです。今回は使用しません。
      google.maps.places.PlaceSearchPagination インターフェース

    まずはrequestの内容を記載します。今回は以下3つを指定します。

    • location
      検索場所を指定します。今回はnearbySearchCafe()の引数に指定したpositionを渡します。
    • radius
      locationから半径を指定して検索範囲をメートル単位で指定します。 今回は半径500mで検索してみます。
    • type
      検索する場所のタイプを指定します。 今回は”cafe”を指定します。他にもいくつかのタイプが用意されています。
      有効なタイプ一覧
    // Map.tsx
    
    // ...省略
    
    const Map = () => {
      // ...省略
    	
      // 追加
      // 近場のカフェをサーチする。
      const nearbySearchCafe = useCallback(
        (
          position: google.maps.LatLng | google.maps.LatLngLiteral,
          callback: (place: google.maps.places.PlaceResult) => void
        ) => {
          // 追加
          placesService?.nearbySearch(
            // requestを記載
            {
              location: position, // 検索場所
              radius: 500, // 検索範囲(メートル単位で指定)
              type: "cafe", // 検索したい場所タイプ
            },
            // callbackの処理
          )
        },[]);
    
      // ...省略
    };
    
    
    // ...省略

    まずはcallbackの内容を記載します。
    今回は引数に検索結果のresultと検索完了時の状態のstatusを指定します。
    条件分岐で、もし検索結果が何もなかった場合は、positionとして渡していた位置を返すようにしておきます。
    Google Mapの座標の形式に合わせるために、new google.maps.LatLng(position)でインスタンス化したものを渡します。
    後々自作マーカーに場所の名前を追加しようと思うので、見つけられなかった時の名前をname: "Cafe not found"として付けておきます。
    もし検索結果があれば、一番最初に見つかった場所としてresult[0]を返すようにします。
    最後に忘れずに、useCallback()の依存関係も更新しておきます。

    // Map.tsx
    
    // ...省略
    
    const Map = () => {
      // ...省略
    
      const nearbySearchCafe = useCallback(
        (
          position: google.maps.LatLng | google.maps.LatLngLiteral,
          callback: (place: google.maps.places.PlaceResult) => void
        ) => {
          placesService?.nearbySearch(
            // requestの処理
            {
              location: position, // 検索場所
              radius: 500, // 検索範囲(メートル単位で指定)
              type: "cafe", // 検索したい場所タイプ
            },
    
            // 追加
            // callbackの処理
            (
              results, // 検索結果
              status // 検索完了時の状況
            ) => {
              // 検索結果がない、もしくは正常にできなかった場合はpositionを返す。
              if (
                status !== google.maps.places.PlacesServiceStatus.OK ||
                results === null ||
                results.length === 0
              ) {
                callback({
    	      // わかりやすくするように、見つからなかった時の表示名を決める。
                  name: "Cafe not found",
                  
                  // PlaceResultの型に沿ったオブジェクトの形式で渡す。
                  geometry: {
                    location: new google.maps.LatLng(position),
                  },
                });
                return;
              }
              
              // 最初に見つかった検索結果を返す
              callback(results[0]);
            }
          );
        )
      },
       [placesService] // 依存関係を更新
     );
    
      // ...省略
    };
    
    
    // ...省略

    これでカフェを検索する機能が実装できました。
    次はクリックした位置の周辺のカフェを検索できるようにします。

    onClickMapCallback()に、クリックした座標potisionと検索結果をStateに格納するsetPlacesを引数に渡したnearbySearchCafe(potision, setPlaces)を追加します。
    これでクリックした座標近くカフェを検索し、カフェの情報がStateに格納されるようになりました。

    // Map.tsx
    
    // ...省略
    
    const Map = () => {
      // ...省略
    	
     const onClickMapCallback = useCallback(
        (position: google.maps.LatLng) => {
          // 削除
          // setPlaces({
          //  geometry: {
          //    location: position,
          //  },
          //});
          
          nearbySearchCafe(position, setPlaces); // 追加
        },
        [nearbySearchCafe, setPlaces] // 依存関係にもnearbySearchCafeを追加
      );
    
    	// ...省略
    };
    
    
    // ...省略

    わかりやすいように自作マーカーにカフェの名前も表示できるように調整します。
    Marker.tsxに名前のスタイルと名前を表示する<span>を追加します。

    // Marker.tsx
    import { CSSProperties, FC } from "react";
    import { OverlayView } from "./OverlayView_copy";
    
    // ...省略
    
    // 追加
    // 名前表示のスタイル
    const nameStyle: CSSProperties = {
      display: "block",
      position: "absolute",
      top: "100%",
      left: "-50%",
      borderRadius: "4px",
      padding: "4px",
      width: "max-content",
      color: "white",
      fontWeight: "bold",
      background: "grey",
    };
    
    export const Marker: FC<{
      place: google.maps.places.PlaceResult;
      map?: google.maps.Map;
    }> = ({ place, map }) => {
      return (
        <>
          {map && place.geometry?.location && (
            <OverlayView position={place.geometry.location} map={map}>
              <div style={wrapperStyle}>
                <span style={iconStyle}>x</span>
    
                {/* 追加 */}
                <span style={nameStyle}>{place.name}</span>
              </div>
            </OverlayView>
          )}
        </>
      );
    };
    

    これでクリック時にカフェの検索ができるようになりました。
    試しにクリックしてみると、クリックした位置でカフェの場所を検索して、カフェの場所に自作マーカーとカフェの名前が表示されています。

    五芒星の描き方

    次は、五芒星を描く処理を書いていきます。 三角関数をうまく利用すると、2点の座標から五芒星を描くことができるので、それをコード上で実装します。
    はじめに、どのような仕組みで2点の座標から五芒星を描けるかを整理します。 今回はあくまで@googlemaps/react-wrapperの解説記事になるため、数学周りの細かい説明は省かせていただきます。

    1. 座標2点の半径(長さ)rを求める。
    2. 座標2点の角度θ(シータ)を求める。
    3. 求めた角度θから72°(360° / 5)ずつずらして、求めた半径rの位置を頂点とする。
    4. 各頂点を順番に0,1,2,3,4と番号を振る(=昇順にソートする)。
    5. 0 → 2, 2 → 4, …と番号を2つ飛ばしでラインを引くと五芒星が完成する。

    まずは1~3の処理を書いて、五芒星の頂点の位置を算出していきます。
    頂点の算出ができたら、頂点付近のカフェの検索とカフェの場所へ自作マーカーを描画する処理も加えていきます。

    1.座標2点の半径(長さ)rを求める

    座標Aの(x1, y1)と座標B(x2, y2)の半径(長さ)は、下記の公式で求めることができます。

    √(x2 - x1)^2 + (y2 - y1)^2

    この公式をJavaScriptでは、平方根の計算ができるMath.sqrt(x)とべき乗の計算ができるMath.pow(base, exponent)を使って書くことができます。

    // 数値は仮です。
    const pointA = {
      x: 3,
      y: 3
    }
    
    const pointB = {
      x: 2,
      y: 5
    }
    
    // 座標2点の半径(長さ)を求める。
    const radius = Math.sqrt(
      Math.pow(pointB.x - pointA.x, 2) + Math.pow(pointB.y - pointA.y, 2)
    );
    

    この公式は後ほど使用します。

    2.座標2点の角度θ(シータ)を求める

    座標Aの(x1, y1)と座標B(x2, y2)の角度は、逆三角関数のアークタンジェントで求めることができます。アークタンジェントを簡単に説明すると、直角三角形の2辺の直線の比率から角度を求める公式です。
    理屈がイマイチわからなくても、JavaScriptにはアークタンジェントを計算できるMath.atan2(y, x)が用意されているので活用します。直線の長さを引数に入れれば、角度をラジアンの値で求めてくれます。
    直線の長さはx = x2 - x1 または y = y2 - y1で求めることができます。

    // 数値は仮です。
    const pointA = {
      x: 3,
      y: 3
    }
    
    const pointB = {
      x: 2,
      y: 5
    }
    
    // アークタンジェントで2点の座標から角度を求める。
    const getTheta =  Math.atan2(pointB.x - pointA.x, pointB.y - pointA.y);
    
    // 度数法(360°)で表記したい場合は、下記の計算を加えます。 Math.PI => π(円周率)
    // console.log((getTheta * 180) / Math.PI)

    上記の関数getThetaをGoogle Mapの座標で使えるように調整をして、Map.tsxのコンポーネント外に宣言しておきます。

    // Map.tsx
    
    // ...省略
    
    // 追加
    // 引数にGoogle Map上の座標を2点を受け取る。
    const getTheta = (a: google.maps.LatLng, b: google.maps.LatLng) => {
      return Math.atan2(b.lat() - a.lat(), b.lng() - a.lng());
    };
    
    const Map = () => {
      // ...省略
    };
    
    
    // ...省略

    3.五芒星の頂点を求める

    残りの頂点を求める関数getRemainingVerticesをコンポーネント外で宣言します。引数には2点の座標start: google.maps.LatLngcenter: google.maps.LatLngを受け取ります。このstartcenterはクリックした位置となります。
    また、頂点の数を計算で使うので、こちらもVERTICES_COUNTとして宣言しておきます。今回は五芒星になるので、頂点の数は5です。

    // Map.tsx
    
    // ...省略
    
    // 追加
    // 頂点の数、今回は五芒星なので5にする。
    const VERTICES_COUNT = 5;
    
    const getTheta = (a: google.maps.LatLng, b: google.maps.LatLng) => {
      return Math.atan2(b.lat() - a.lat(), b.lng() - a.lng());
    };
    
    // 追加
    const getRemainingVertices = (
      // 2点の座標を引数として受け取る。
      start: google.maps.LatLng,
      center: google.maps.LatLng
    ) => {
      // ...処理
    };
    
    const Map = () => {
      // ...省略
    };
    
    // ...省略

    頂点を求めるには、

    • unitTheta:五等分の指標になる360°の1/5の角度である72°
    • baseTheta: 座標2点の角度
    • radius: 座標2点の半径

    が必要になるため、関数getRemainingVertices内で計算します。
    unitThetaは、2πラジアンで求められる360°を頂点の数で割ることで算出します(算出される数値は度数法ではなくラジアン単位になります)。
    baseThetaは、先ほど宣言したgetThetaに引数のcenterstartを入れて算出します。 
    radiusは、先に紹介した2点の座標の長さを求める公式√(x2 - x1)^2 + (y2 - y1)^2を使って算出します。

    // Map.tsx
    
    // ...省略
    
    const VERTICES_COUNT = 5;
    
    const getTheta = (a: google.maps.LatLng, b: google.maps.LatLng) => {
      return Math.atan2(b.lat() - a.lat(), b.lng() - a.lng());
    };
    
    const getRemainingVertices = (
      start: google.maps.LatLng,
      center: google.maps.LatLng
    ) => {
      // 追加
      // 五等分の指標になる360°の1/5の角度である72°を算出
      const unitTheta = (Math.PI * 2) / VERTICES_COUNT;
    
      // クリックした2点の座標の角度を算出
      const baseTheta = getTheta(center, start);
    
      // クリックした2点の座標の長さを算出
      const radius = Math.sqrt(
        Math.pow(start.lat() - center.lat(), 2) +
          Math.pow(start.lng() - center.lng(), 2)
      );
    };
    
    // ...省略

    算出されたこれらの値を使い、頂点の座標を算出します。
    座標(0, 0)を中心とした時、もう1点のx座標は半径 × sinθで、y座標は半径 × cosθで求めることができます。
    sinθやcosθについて覚えていなくても、こちらもJavaScriptがよしなに計算をしてくれます。
    中心の座標を引数のcenterに合わせて、それぞれMath.sin(x)Math.cos(x)で頂点の座標を求めることができます。xには角度unitTheta + baseThetaが入ります。
    最初の点(2回目のクリック)以外の頂点を求めるので、VERTICES_COUNT - 1 = 4回処理を繰り返して、その都度unitThetaの値も回数に合わせて増加させます。
    まとめると、下記の処理で2点の座標から残りの頂点を求めることができます。

    // Map.tsx
    
    // ...省略
    
    const getRemainingVertices = (
      start: google.maps.LatLng,
      center: google.maps.LatLng
    ) => {
      // ...省略
    	
      // 追加
      return [...Array(VERTICES_COUNT - 1)].map((_, index) => {
        return {
          lat:
            center.lat() + radius * Math.sin(unitTheta * (index + 1) + baseTheta),
          lng:
            center.lng() + radius * Math.cos(unitTheta * (index + 1) + baseTheta),
        };
      });
    };
    
    // ...省略

    3a.五芒星の頂点と中心付近のカフェを検索する

    五芒星の頂点を算出することができたので、nearbySearchCafe()を使って算出した頂点付近のカフェを検索できるようにします。
    useEffect()を利用して、2点クリックした時点で残りの頂点を計算できるようにします。
    残りの頂点の座標を保存するStateのremainingPlacesを準備します。

    // Map.tsx
    
    // ...省略
    
    const Map = () => {
      // ...省略
    	
      // 追加
      // 残りの頂点を保存するState
      const [remainingPlaces, setRemainingPlaces] = useState<
        google.maps.places.PlaceResult[]
      >([]);
    
      // ...省略
    };
    
    
    // ...省略

    useEffect()を使い、算出された各座標に対してnearbySearchCafeを実行します。検索結果は先程準備したremainingPlacesに格納していきます。
    setState((prev) ⇒ […prev, result])の書き方で計算結果をStateに蓄積させることができます。

    // Map.tsx
    
    // ...省略
    
    const Map = () => {
      // ...省略
    
      
      // 追加
      // 残りの頂点の計算はuseEffect()で実行する。
      useEffect(() => {
        // Google Mapが読み込まれていない、
        // もしくは2点の座標が決まっていなければ処理を実行しない。
        if (
          !placesService ||
          !centerPlace?.geometry?.location ||
          !firstPlace?.geometry?.location
        ) {
          return;
        }
    
        // 2点の座標から残り4点の頂点の座標を算出する。
        const remainingVertices = getRemainingVertices(
          firstPlace.geometry.location,
          centerPlace.geometry.location
        );
    
        // 算出されたそれぞれの座標を使ってカフェの場所を検索して、
        // 検索結果をsetRemainingPlaces()で格納する。
        remainingVertices.forEach((vertice) => {
          nearbySearchCafe(vertice, (place) => {
            setRemainingPlaces((currentPlaces) => {
              return [...currentPlaces, place];
            });
          });
        });
      }, [nearbySearchCafe, placesService, centerPlace, firstPlace]);
    
      // ...省略
    };
    
    
    // ...省略

    setPlacesの3回目のクリック時の処理に、残りの頂点を格納しているremainingPlacesを初期化する処理を追加します。

    // Map.tsx
    
    // ...省略
    
    const Map = () => {
      // ...省略
    	
      const setPlaces = useCallback(
        (place: google.maps.places.PlaceResult) => {
          if (!centerPlace) {
            setCenterPlace(place);
          } else if (!firstPlace) {
            setFirstPlace(place);
          } else {
            setCenterPlace(undefined);
            setFirstPlace(undefined);
            
            // 追加
            // remainingPlacesを初期化
            setRemainingPlaces([]);
          }
        },
        [centerPlace, firstPlace]
      );
    
      // ...省略
    };
    
    
    // ...省略

    これで算出した各頂点の位置でカフェを検索してくれるようになりました。3回目のクリックで全ての位置はリセットされます。

    3b.検索されたカフェの場所に自作マーカーを描画する

    算出された頂点で検索されたカフェの位置にも自作マーカーを描画します。
    算出された頂点付近のカフェの情報を格納しているremainingPlacesmapで展開して中身を<Marker>に渡します。
    mapで展開しているため、Reactがそれぞれの<Marker>を識別できるようにkeyも付与してあげます。remainingPlacesに格納されているオブジェクトには、カフェの場所以外にユニークなplace_idも含まれているので、今回はこれをkeyに利用することにします。

    // Map.tsx
    
    // ...省略
    
    const Map = () => {
    	
      // ...省略
    
      return (
        <Wrapper
          apiKey={MAP_API_KEY}
          callback={onLoadGoogleMap}
          libraries={["places"]}
        >
          <Content onClickMapCallback={onClickMapCallback}>
            {centerPlace && <Marker place={centerPlace} />}
            {firstPlace && <Marker place={firstPlace} />}
            
            {/* 追加 mapで残りの頂点を描画 */}
            {remainingPlaces.map((place) => (
              <Marker key={place.place_id} place={place} />
            ))}
    
            {polylinePath && <PolyLine path={polylinePath} />}
          </Content>
          <div ref={placesRef} />
        </Wrapper>
      );
    };
    
    
    // ...省略

    これで各頂点にも自作マーカーが描画されるようになりました。
    試しにGoogle Map上で2箇所をクリックしてみると、自動で残りの頂点を算出、付近のカフェを検索して、その場所に自作マーカーが描画されました。

    4.五芒星の頂点をソートする

    次は五芒星を描く準備を進めます。

    前回の記事で作成したラインを描画する<PolyLine>は、座標を配列で受け取ると隣のインデックス同士でラインを結びます。つまり、配列の[0]と[1]、[1]と[2]、[2]と[3]…でラインを描画していきます。
    五芒星の頂点を順番に番号を振ったとして、0 → 2, 2 → 4, …と番号を2つ飛ばしで線を引くと一筆書きで五芒星を描くことができます。
    今回はこれらの仕様を利用して五芒星を描画します。

    頂点の座標は既に算出しているので、頂点の位置を順番にソートした配列を<PolyLine>に渡せれば五芒星が描画できそうです。

    <PolyLine>pathに渡すpolylinePathを調整していきます。
    まずは各頂点をひとつにまとめたsortedPointsを宣言して、sort()を使い配列をソートします。getTheta()を利用して各頂点の角度を算出して、角度順にソートすることで円に沿う形で各頂点の順番をソートすることができます。
    合わせて、useMemo()の依存関係も更新しておきます。

    // Map.tsx
    
    // ...省略
    
    const Map = () => {
    	
      // ...省略
    
      // ラインを描画する位置
      const polylinePath = useMemo(() => {
        // 削除
        // if (!centerPlace?.geometry?.location || !firstPlace?.geometry?.location) {
        //   return undefined;
        // }
        
        // 追加
        // 中心と各頂点が揃っていなければundefinedを返す。
        if (
          !centerPlace?.geometry?.location ||
          !firstPlace?.geometry?.location ||
          !remainingPlaces
        ) {
          return undefined;
        }
    
        // 追加
        // 中心となる座標を関数に代入して、型を固定させる
        const center = centerPlace?.geometry?.location;
    
        // 追加
        // 各頂点の位置をひとつの配列にまとめて、型を固定させる
        // 今回の場合は、getRemainingVerticesで最初の2点から他の頂点を算出していてundefinedの場合がないので、型アサーションで型エラーを解決
        const points = [firstPlace, ...remainingPlaces].map(
          (place) => place.geometry?.location
        ) as google.maps.LatLng[];
    
        // 追加
        // 各頂点を配列にしてsort()でソートする。
        const sortedPoints = points
          // getTheta()とcenterPlaceを利用して各頂点の角度を算出する。
          .sort((a, b) => {
            const aTheta = getTheta(center, a);
            const bTheta = getTheta(center, b);
            
            // 角度を参照して昇順でソートする。
            return aTheta - bTheta;
          })
          
          // ...省略
      }, [centerPlace, firstPlace, remainingPlaces]); // 依存関係を更新する。
      
      // ...省略
    };
    
    
    // ...省略

    ソートした順番を、0 → 2, 2 → 4, …と2つ飛ばしした順番に並び替えます。
    reduce()を利用することで目的の順番になった配列を作ることができます。

    // Map.tsx
    
    // ...省略
    
    const Map = () => {
    	
      // ...省略
    
      const polylinePath = useMemo(() => {
        if (
          !centerPlace?.geometry?.location ||
          !firstPlace?.geometry?.location ||
          !remainingPlaces
        ) {
          return undefined;
        }
    
        const center = centerPlace?.geometry?.location;
        const points = [firstPlace, ...remainingPlaces].map(
          (place) => place.geometry?.location
        ) as google.maps.LatLng[];
    
        const sortedPoints = points
          .sort((a, b) => {
            const aTheta = getTheta(center, a);
            const bTheta = getTheta(center, b);
            return aTheta - bTheta;
          })
          
          // 追加
          .reduce((acc, place, index, origin) => {
            // 飛ばしたい数字を求める。今回は2つずつ飛ばしたいので、
            // 配列の数(頂点の数)が奇数であれば2にする。
            // 偶数の場合は配列の順序に影響が内容に1としておく。
            const skip = origin.length % 2 === 0 ? 1 : 2;
    
            // 配列のindexに飛ばしたい数をかけて頂点の数で割ると、
            // 余りの数値がキレイに2つ飛ばしになる!
            const newIndex = (index * skip) % origin.length;
    
            // 計算で算出したnewIndexを使い配列を並び替える。
            acc[newIndex] = place;
            return acc;
          }, 
            // 型を合わせるために、initialValueの型をアサーションする。
            [] as google.maps.LatLng[]
          );
          
          // ...省略
      }, [centerPlace, firstPlace, remainingPlaces]);
      
      // ...省略
    };
    
    
    // ...省略

    5.五芒星を描画する

    最後にpolylinePathの戻り値を調整します。
    最初の頂点と最後の頂点にもラインを結びたいので、並び替えた配列sortedPointsの最後にsortedPoints[0]を追加した配列をmapで展開して、座標のみを取り出してpolylinePathの戻り値とします。

    // Map.tsx
    
    // ...省略
    
    const Map = () => {
    	
      // ...省略
    
      const polylinePath = useMemo(() => {
        if (
          !centerPlace?.geometry?.location ||
          !firstPlace?.geometry?.location ||
          !remainingPlaces
        ) {
          return undefined;
        }
    
        const center = centerPlace?.geometry?.location;
        const points = [firstPlace, ...remainingPlaces].map(
          (place) => place.geometry?.location
        ) as google.maps.LatLng[];
    
        const sortedPoints = points
          .sort((a, b) => {
            const aTheta = getTheta(center, a);
            const bTheta = getTheta(center, b);
            return aTheta - bTheta;
          })
          .reduce((acc, place, index, origin) => {
            const skip = origin.length % 2 === 0 ? 1 : 2;
            const newIndex = (index * skip) % origin.length;
            acc[newIndex] = place;
            return acc;
          }, 
            [] as google.maps.LatLng[]
          );
          
          // 削除
          // return [firstPlace.geometry.location, secondPlace.geometry.location];
          
          // 追加
          // 最後の点と最初の点をラインで結びたいので、sortedPoints[0]を最後に加える。
          return [...sortedPoints, sortedPoints[0]].map((place) => {
    	// mapで展開して座標のみをreturnする。
            return place;
          });
      }, [centerPlace, firstPlace, remainingPlaces]); 
      
      // ...省略
    };
    
    
    // ...省略

    polylinePathは既に<PolyLine>渡しているので、これで五芒星が描画されていれば完成です。
    お疲れ様でした!

    完成コード

    // Marker.tsx
    import { CSSProperties, FC } from "react";
    import { OverlayView } from "./OverlayView";
    
    // マーカーにスタイルを当てる
    const wrapperStyle: CSSProperties = {
      position: "absolute",
      top: 0,
      left: 0,
      display: "flex",
      flexDirection: "column",
      alignItems: "center",
      width: "fit-content",
      transform: "translate(-50%, -50%)",
    };
    
    // マーカーアイコンのスタイル
    const iconStyle: CSSProperties = {
      fontSize: "56px",
      fontWeight: "bold",
      color: "red",
    };
    
    // 名前表示のスタイル
    const nameStyle: CSSProperties = {
      display: "block",
      position: "absolute",
      top: "100%",
      left: "-50%",
      borderRadius: "4px",
      padding: "4px",
      width: "max-content",
      color: "white",
      fontWeight: "bold",
      background: "grey",
    };
    
    export const Marker: FC<{
      place: google.maps.places.PlaceResult;
      map?: google.maps.Map;
    }> = ({ place, map }) => {
      return (
        <>
          {map && place.geometry?.location && (
            // 自作マーカーを<OverlayView>に渡してオーバーレイにする。
            <OverlayView position={place.geometry.location} map={map}>
              <div style={wrapperStyle}>
                <span style={iconStyle}>x</span>
                <span style={nameStyle}>{place.name}</span>
              </div>
            </OverlayView>
          )}
        </>
      );
    };
    
    
    // Map.tsx
    import React, {
      Children,
      cloneElement,
      FC,
      isValidElement,
      ReactElement,
      ReactNode,
      useCallback,
      useEffect,
      useMemo,
      useRef,
      useState,
    } from "react";
    import { Wrapper, Status } from "@googlemaps/react-wrapper";
    import { Marker } from "./Marker";
    import { PolyLine } from "./PolyLine";
    
    const MAP_API_KEY = "xxxx"; // 第1弾の記事で生成したAPIキー
    
    const VERTICES_COUNT = 5;
    
    const getTheta = (a: google.maps.LatLng, b: google.maps.LatLng) => {
      return Math.atan2(b.lat() - a.lat(), b.lng() - a.lng());
    };
    
    const getRemainingVertices = (
      start: google.maps.LatLng,
      center: google.maps.LatLng
    ) => {
      const unitTheta = (Math.PI * 2) / VERTICES_COUNT;
      const baseTheta = getTheta(center, start);
      const radius = Math.sqrt(
        Math.pow(start.lat() - center.lat(), 2) +
          Math.pow(start.lng() - center.lng(), 2)
      );
    
      return [...Array(VERTICES_COUNT - 1)].map((_, index) => {
        return {
          lat:
            center.lat() + radius * Math.sin(unitTheta * (index + 1) + baseTheta),
          lng:
            center.lng() + radius * Math.cos(unitTheta * (index + 1) + baseTheta),
        };
      });
    };
    
    // 最初にMapを表示する時の設定
    const DEFAULT = {
      CENTER: {
        lat: 35.6973225,
        lng: 139.8265658,
      } as google.maps.LatLngLiteral,
      ZOOM: 16,
    } as const;
    
    // width指定がないと描画されない。
    const VIEW_STYLE = {
      width: "100%",
      aspectRatio: "16 / 9",
    };
    
    const Map: FC = () => {
      const placesRef = useRef(null);
    
      const [hasMapLoaded, setHasMapLoaded] = useState(false);
    
      const [placesService, setPlacesService] =
        useState<google.maps.places.PlacesService>();
    
      const [centerPlace, setCenterPlace] =
        useState<google.maps.places.PlaceResult>();
      const [firstPlace, setFirstPlace] =
        useState<google.maps.places.PlaceResult>();
    
      const [remainingPlaces, setRemainingPlaces] = useState<
        google.maps.places.PlaceResult[]
      >([]);
    
      const onLoadGoogleMap = useCallback(
        (status: Status) => {
          if (status !== Status.SUCCESS || hasMapLoaded) return;
          setHasMapLoaded(true);
        },
        [hasMapLoaded]
      );
    
      const nearbySearchCafe = useCallback(
        (
          position: google.maps.LatLng | google.maps.LatLngLiteral,
          callback: (place: google.maps.places.PlaceResult) => void
        ) => {
          placesService?.nearbySearch(
            // requestを記載
            {
              location: position, // 検索場所
              radius: 500, // 検索範囲(メートル単位で指定)
              type: "cafe", // 検索したい場所タイプ
            },
            (
              results, // 検索結果
              status // 検索完了時の状況
            ) => {
              // 検索結果がない、もしくは正常にできなかった場合はpositionを返す。
              if (
                status !== google.maps.places.PlacesServiceStatus.OK ||
                results === null ||
                results.length === 0
              ) {
                callback({
                  // わかりやすくするように、見つからなかった時の表示名を決める。
                  name: "Cafe not found",
    
                  // PlaceResultの型に沿ったオブジェクトの形式で渡す。
                  geometry: {
                    location: new google.maps.LatLng(position),
                  },
                });
                return;
              }
    
              // 最初に見つかった検索結果を返す
              callback(results[0]);
            }
          );
        },
        [placesService]
      );
    
      useEffect(() => {
        if (!placesRef.current || !hasMapLoaded) return;
        setPlacesService(new google.maps.places.PlacesService(placesRef.current));
      }, [placesRef, hasMapLoaded]);
    
      useEffect(() => {
        if (
          !placesService ||
          !centerPlace?.geometry?.location ||
          !firstPlace?.geometry?.location
        ) {
          return;
        }
    
        const remainingVertices = getRemainingVertices(
          firstPlace.geometry.location,
          centerPlace.geometry.location
        );
    
        remainingVertices.forEach((vertice) => {
          nearbySearchCafe(vertice, (place) => {
            setRemainingPlaces((currentPlaces) => {
              return [...currentPlaces, place];
            });
          });
        });
      }, [nearbySearchCafe, centerPlace, firstPlace, placesService]);
    
      const polylinePath = useMemo(() => {
        if (
          !centerPlace?.geometry?.location ||
          !firstPlace?.geometry?.location ||
          !remainingPlaces
        ) {
          return undefined;
        }
    
        const center = centerPlace?.geometry?.location;
        const points = [firstPlace, ...remainingPlaces].map(
          (place) => place.geometry?.location
        ) as google.maps.LatLng[];
    
        const sortedPoints = points
          // getTheta()とcenterPlaceを利用して各頂点の角度を算出する。
          .sort((a, b) => {
            const aTheta = getTheta(center, a);
            const bTheta = getTheta(center, b);
            return aTheta - bTheta;
          })
          .reduce(
            (acc, place, index, origin) => {
              // 飛ばしたい数字を求める。今回は2つずつ飛ばしたいので、
              // 配列の数(頂点の数)が奇数であれば2にする。
              // 偶数の場合は配列の順序に影響が内容に1としておく。
              const skip = origin.length % 2 === 0 ? 1 : 2;
    
              // 配列のindexに飛ばしたい数をかけて頂点の数で割ると、
              // 余りの数値がキレイに2つ飛ばしになる!
              const newIndex = (index * skip) % origin.length;
    
              // 計算で算出したnewIndexを使い配列を並び替える。
              acc[newIndex] = place;
              return acc;
            },
            // 型を合わせるために、initialValueの型をアサーションする。
            [] as google.maps.LatLng[]
          );
    
        return [...sortedPoints, sortedPoints[0]].map((place) => {
          // mapで展開して座標のみをreturnする。
          return place;
        });
      }, [centerPlace, firstPlace, remainingPlaces]); // 依存関係を更新する。
    
      const setPlaces = useCallback(
        (place: google.maps.places.PlaceResult) => {
          if (!centerPlace) {
            // 1回目のクリック時の処理
            setCenterPlace(place);
          } else if (!firstPlace) {
            // 2回目のクリック時の処理
            setFirstPlace(place);
          } else {
            // 3回目のクリック時の処理、座標を保存しているStateを初期化する。
            setCenterPlace(undefined);
            setFirstPlace(undefined);
            setRemainingPlaces([]);
          }
        },
        [centerPlace, firstPlace]
      );
    
      const onClickMapCallback = useCallback(
        (position: google.maps.LatLng) => {
          nearbySearchCafe(position, setPlaces);
        },
        [nearbySearchCafe, setPlaces]
      );
    
      return (
        <Wrapper
          apiKey={MAP_API_KEY}
          callback={onLoadGoogleMap}
          libraries={["places"]}
        >
          <Content onClickMapCallback={onClickMapCallback}>
            {centerPlace && <Marker place={centerPlace} />}
            {firstPlace && <Marker place={firstPlace} />}
            {remainingPlaces.map((place) => (
              <Marker key={place.place_id} place={place} />
            ))}
            {polylinePath && <PolyLine path={polylinePath} />}
          </Content>
          <div ref={placesRef} />
        </Wrapper>
      );
    };
    
    const Content: FC<{
      onClickMapCallback: (position: google.maps.LatLng) => void;
      children: ReactNode;
    }> = ({
      onClickMapCallback,
      children,
    }) => {
      const ref = useRef<HTMLDivElement>(null);
      const [map, setMap] = useState<google.maps.Map>();
    
      useEffect(() => {
        if (ref.current && !map) {
          const option = {
            center: DEFAULT.CENTER,
            zoom: DEFAULT.ZOOM,
          };
          setMap(new window.google.maps.Map(ref.current, option));
        }
      }, []);
    
      const onClick = useCallback(
        (event: google.maps.MapMouseEvent) => {
          if (event.latLng) onClickMapCallback(event.latLng);
        },
        [onClickMapCallback]
      );
    
      useEffect(() => {
        if (map && onClick) {
          map.addListener("click", onClick);
        }
      }, [onClick, map]);
    
      useEffect(() => {
        if (map) {
          google.maps.event.clearListeners(map, "click");
          if (onClick) {
            map.addListener("click", onClick);
          }
        }
      }, [onClick, map]);
    
      return (
        <div style={VIEW_STYLE} ref={ref}>
          {Children.map(children, (child) => {
            if (!isValidElement(child)) return;
            return cloneElement(child as ReactElement, { map });
          })}
        </div>
      );
    };
    
    export default Map;
    
    

    まとめ

    シリーズを通して@googlemaps/react-wrapperで自作マーカーと自作ポリラインを描画する方法を紹介していきました。参考になれば幸いです。

    Share

    1. 01 Google Maps Platformと@googlemaps/react-wrapperの準備 >
    2. 02 @googlemaps/react-wrapperでGoogle Map上に自作マーカーを描画する >
    3. 03 @googlemaps/react-wrapperでGoogle Map上をクリックした2点の間にラインを描画する >
    4. 04 @googlemaps/react-wrapperでGoogle Map上に五芒星を描画する
    @googlemaps/react-wrapperでGoogle Map上をクリックした2点の間にラインを描画する