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

@googlemaps/react-wrapperでGoogle Map上をクリックした2点の間にラインを描画する

    目次

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

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

    前回は、自作マーカーをGoogle Map上に描画させるところまで進めました。
    今回の記事では、Google Mapをクリックした2点の間に線を描画する方法を紹介します。

    完成形はこちらになります。
    クリックした場所に自作マーカーを描画して、2点の間に黒いラインをGoogle Map上に描画しています。このラインも自作マーカーと同様に、Google Mapのドラッグやズームに連動して動きます。

    これを実現するには、ラインを描画する仕組みクリックしたGoogle Map上の位置を取得する仕組みの2つが必要です。
    今回はこれらの実装方法について紹介します。

    環境

    • typescript: v5.0.2
    • react: v18.2.0
    • next: v13.2.4
    • @googlemaps/react-wrapper: v1.1.35

    ラインを描画する関数を宣言する

    ラインを描画する仕組みを作成していきます。

    Google Map上にポリラインを描くにはgoogle.maps.Polylineクラスを使用します。
    Polyline(ポリライン)は多角線を意味していて、このクラスを使えば複数のラインを連結させて折線や図形も描画することができます。
    今回は1本しか描画させませんが、本シリーズの最終目標である五芒星の描画もこのクラスを使用して行います。

    まずはgoogle.maps.Polylineクラスを使用してポリラインを描画するコンポーネントPolyLineを宣言します。
    引数には下記の2つを指定します。

    1. path: (google.maps.LatLng | google.maps.LatLngLiteral)[]
      Google Map上の経度と緯度で表される座標を配列で受け取ります。ここで受け取った座標と座標の間にラインを描画します。
    2. map?: google.maps.Map
      ポリラインを描画するGoogle Mapを受け取ります。
    // Polyline.tsx
    export const PolyLine: FC<{
      // ポリラインを描画する位置
      path: (google.maps.LatLng | google.maps.LatLngLiteral)[];
    
      // ポリラインを描画するGoogle Mapの指定
      map?: google.maps.Map;
    }> = ({ path, map }) => {
      // 処理
    }
    

    今回はReactで作成しているので、ポリラインの状態をStateで管理します。
    ポリラインの生成にはuseEffect()を使用します。マウント時にポリラインをStateに格納して、アンマウント時にはGoogle Mapからポリラインを削除する処理を書きます。

    // Polyline.tsx
    export const PolyLine: FC<{
      path: (google.maps.LatLng | google.maps.LatLngLiteral)[];
      map?: google.maps.Map;
    }> = ({ path, map }) => {
      // ポリラインを格納するState、これでポリラインの描画を制御する。
      const [polyline, setPolyline] = useState<google.maps.Polyline>();
    
      // polylineの描画を制御
      useEffect(() => {
        // ポリラインがまだ存在していなければ、Stateにポリラインのインスタンスを格納する。
        if (!polyline) {
          setPolyline(new google.maps.Polyline());
        }
    
        // アンマウント時にGoogle Mapからポリラインを削除する。
        return () => {
          if (polyline) {
            polyline.setMap(null);
          }
        };
      }, [polyline]);
    }
    

    生成したポリラインを描画する処置を書きます。
    polyline.setOptions()を呼び出し、描画するGoogle Mapや位置などを指定して、ポリラインの描画設定を行います。ラインの色や太さなどのオプションも設定できます。
    PolylineOptionsインターフェース

    // Polyline.tsx
    export const PolyLine: FC<{
      // ...省略
      
      // ポリラインの描画を制御する。  
      useEffect(() => {
        if (map && polyline) {
          // Google Mapとポリラインが存在していたら、描画の設定を行う。
          polyline.setOptions({
            map: map, // 描画するGoogle Mapの指定
            path, // ラインを描画する位置
            strokeWeight: 4, // 線の太さ
            strokeColor: "black", // 線の色
            strokeOpacity: 1, // 線の透明度
          });
        }
      }, [map, path, polyline]);
    }
    

    コンポーネントの戻り値はnullにします。
    このコンポーネントはあくまでuseEffectでポリラインの状態を更新してGoogle Map内に描画するためのもので、コンポーネント本体で描画するものはないのでnullとしています。

    // Polyline.tsx
    export const PolyLine: FC<{
      // ...省略
      
      //追加
      return null;
    }
    

    これでラインを描画する準備は整いました。

    Google Mapにラインを描画する

    今回作成するのはクリックした2点の位置の間にラインを描画するというものです。
    そのため、必要な処理は以下の3つと考えられます。

    • クリックした位置のGoogle Mapの座標を取得する
    • クリックした位置に自作マーカーを描画する
    • 2点をクリックしたら、その間にラインを描画する

    この順番でMap.tsxに直接処理を書いていきます。

    クリックでGoogle Mapの座標を取得する

    Maps JavaScript APIにはGoogle Map用のeventクラスが用意されているので、それを活用してクリックした位置の座標を取得します。

    Map.tsx<content>onClick()を追加します。
    引数にevent: google.maps.MapMouseEventを取り、処理にconsole.log(e)を記載しておきます。この時点ではまだGoogle Mapのイベントは取得できていませんのでご注意ください。

    // Map.tsx
    
    // ..省略
    
    const Content: FC<{
      // ..省略
      
      // 追加
      // この時点ではまだGoogle Mapのeventを取得できないので注意。
      const onClick = useCallback((event: google.maps.MapMouseEvent) => {
        console.log(event);
      }, []);
    
      return (
        <div style={VIEW_STYLE} ref={ref}>
          {cloneElement(children as ReactElement, { map })}
        </div>
      );
    };
    
    export default Map;

    Google Map上のeventの取得はmap.addListener()を使用します。
    Google Map上で指定したイベント(今回の場合はclick)が発生したら、指定した関数を発火させることができます。ウェブページにおけるdocument.addEventListenerに相当するものです。
    map.addListener()"click"onClick()を指定して、useEffect()で制御します。

    // Map.tsx
    
    // ..省略
    
    const Content: FC<{
      // ..省略
    
      // 追加
      useEffect(() => {
        // mapと動作させたい関数(今回はonClick)が存在していたらaddListener()を呼び出す。
        if (map && onClick) {
            // 第1引数にイベント名の"click"を指定する。
            // 第2引数に実行する関数onClickを指定する。
            map.addListener("click", onClick);
        }
      }, [onClick, map]);
    
      return (
        <div style={VIEW_STYLE} ref={ref}>
          {cloneElement(children as ReactElement, { map })}
        </div>
      );
    };
    
    export default Map;

    これでクリックのイベントでGoogle Mapのeventを取得しながらonClick()を実行することができました。
    試しにGoogle Mapをクリックしてみると、コンソールにGoogle Mapのeventオブジェクトが表示されていることがわかります。
    eventオブジェクトのlatLngから経度と緯度の座標を取得することができます。
    ※警告ログがコンソールに表示されていますが、動作は問題ないためひとまずスルーをして進めます。

    クリックした位置に自作マーカーを描画する

    座標の取得に成功したので、次はその座標の位置に自作マーカーを描画してみます。

    Map.tsx<Map>に移ります。
    今回は2点のクリックした位置を保存したいので、2つのStateを用意してそこに取得した位置を格納するようにします。

    // Map.tsx
    
    // ...省略
    
    const Map: FC = () => {
      // 追加
      // 2点のクリックした位置をそれぞれuseStateで管理
      const [centerPlace, setCenterPlace] = useState<google.maps.places.PlaceResult>();
      const [firstPlace, setFirstPlace] = useState<google.maps.places.PlaceResult>();
      
      return (
        <Wrapper apiKey={MAP_API_KEY}>
          <Content>
            <Marker place={DEFAULT.CENTER} />
          </Content>
        </Wrapper>
      );
    };
    
    // ...省略

    Stateを更新する関数setPlaces()を宣言します。
    1回目にクリックした時はcenterPlaceに、2回目はfirstPlaceにクリックした位置を保存します。また、2点のクリックした位置を更新できるように、3回目にクリックしたらそれぞれのStateを初期化するようにします。

    // Map.tsx
    
    // ...省略
    
    const Map: FC = () => {
      const [centerPlace, setCenterPlace] =
        useState<google.maps.places.PlaceResult>();
      const [firstPlace, setFirstPlace] =
        useState<google.maps.places.PlaceResult>();
      
      // 追加
      const setPlaces = useCallback(
        (place: google.maps.places.PlaceResult) => {
          if (!centerPlace) {
            // 1回目のクリック時の処理
            setCenterPlace(place);
          } else if (!firstPlace) {
            // 2回目のクリック時の処理
            setFirstPlace(place);
          } else {
            // 3回目のクリック時の処理、2つのStateを初期化
            setCenterPlace(undefined);
            setFirstPlace(undefined);
          }
        },
        [centerPlace, firstPlace]
      );
      
      // ...省略
    };
    
    // ...省略

    Google Mapのクリックした座標を取得する関数onClickMapCallback()を宣言します。
    <content>onClick()で取得した座標をsetPlaces()に渡してStateを更新します。
    onClick()内でonClickMapCallback()を実行する必要があるため、propsとして渡しておきます。

    // Map.tsx
    
    // ...省略
    
    const Map: FC = () => {
      // ...省略
    
      // 追加
      const onClickMapCallback = useCallback(
        (position: google.maps.LatLng) => {
          // Google Mapの座標を受け取り、setPlacesでStateを更新する。
          setPlaces({
            geometry: {
              location: position,
            },
          });
        },
        [setPlaces]
      );
      
      return (
        <Wrapper apiKey={MAP_API_KEY}>
          {/* <Content> 削除 */}
          <Content onClickMapCallback={onClickMapCallback}> {/* 追加 */}
            <Marker place={DEFAULT.CENTER} />
          </Content>
        </Wrapper>
      );
    };
    
    // ...省略
    

    クリックした位置とタイミングで自作マーカーを描画したいので、二項演算子で自作マーカーの描画を制御します。

    // Map.tsx
    
    // ...省略
    
    const Map: FC = () => {
      // ...省略  
      return (
        <Wrapper apiKey={MAP_API_KEY}>
          <Content onClickMapCallback={onClickMapCallback}>
            {/* <Marker place={DEFAULT.CENTER} /> 削除 */}
            {centerPlace && <Marker place={centerPlace} />} {/* 追加 */}
            {firstPlace && <Marker place={firstPlace} />} {/* 追加 */}
          </Content>
        </Wrapper>
      );
    };
    
    // ...省略

    ここで<Marker>placeに型エラーが発生するかと思います。
    前回は自作マーカーの位置を直接数値で指定していたので、placeと、propsとして渡していた数値のオブジェクトDEFAULT.CENTERの型をgoogle.maps.LatLngLiteralとしていましたが、今回はGoogle Mapのイベントから座標を取得するので型定義を修正する必要があります。
    placeの型をgoogle.maps.places.PlaceResultに修正します。
    place.geometry.locationにクリックした位置の座標が格納されていますが、place.geometryplace.geometry.locationにはundefinedが許容されているので、描画する条件式にplace.geometry?.locationを加えます。これで、座標を取得できた時だけ自作マーカーが描画されます。
    この状態で<OverlayView>positionplace.geometry.locationを渡すことで型エラーを解消できます。

    // Marker.tsx
    
    // ...省略
    
    export const Marker: FC<{
      // place: google.maps.LatLngLiteral; 削除
      place: google.maps.places.PlaceResult; // 追加
      map?: google.maps.Map;
    }> = ({ place, map }) => {
      return (
        <>
          // {map && ( 削除
          {map && place.geometry?.location && ( // 追加
            <OverlayView
              // position={place} 削除
              position={place.geometry.location} // 追加
              map={map}
            >
              <div style={wrapperStyle}>
                <span style={iconStyle}>x</span>
              </div>
            </OverlayView>
          )}
        </>
      );
    };
    

    <content>に戻り、propsでonClickMapCallback()を受け取れるように追記します。
    onClick()onClickMapCallback()を入れて、Google Mapの座標を取得できるevent.latLngonClickMapCallback()の引数に渡します。event.latLngnullの可能性があるので、event.latLngが存在している時のみonClickMapCallback()を実行するようにします。
    useCallbackの依存関係にもonClickMapCallbackを忘れずに追加します。

    // Map.tsx
    
    // ..省略
    
    const Content: FC<{
      onClickMapCallback: (position: google.maps.LatLng) => void; // 追加
      children: ReactNode;
    }> = ({
      onClickMapCallback, // 追加
      children,
    }) => {
      // ..省略
    
      const onClick = useCallback((event: google.maps.MapMouseEvent) => {
        // console.log(event); 削除
        if (event.latLng) onClickMapCallback(event.latLng); // 追加
      }, 
        [onClickMapCallback] // 依存関係も追加
      );
      
      // ..省略
    };
    
    export default Map;

    useEffect()の処理も修正します。
    onClickMapCallback()の追加により、onClick()の処理の内容がクリック毎に変化するようになりました。
    つまり、1回目のクリックではcenterPlaceを更新する関数、2回目はfirstPlaceを更新する関数、3回目ではそれらをリセットする関数に変わります。
    そのため、クリック毎にmap.addListener("click", onClick)をリセットして入れ直す必要があります。クリックイベントのリセットはgoogle.maps.event.clearListeners(map, "click")で実現できます。

    // Map.tsx
    
    // ..省略
    
    const Content: FC<{
      // ..省略
      }, []);
      
     // 追加
      useEffect(() => {
        if (map && onClick) {
          map.addListener("click", onClick);
        }
      }, [onClick, map]);
      
      
      useEffect(() => {
        // 削除
        // if (map && onClick) {
        //   map.addListener("click", onClick);
        // }
        
        // 追加
        if (map) {
          // クリック毎にonClickを更新するために、クリックイベントをリセットする。
          google.maps.event.clearListeners(map, "click");
          if (onClick) {
            // クリック毎にonClickに更新する。
            map.addListener("click", onClick);
          }
        }
      }, [onClick, map]);
      
      // ..省略
    };
    
    export default Map;

    戻り値の修正をします。
    前回まではGoogle Mapに描画するオーバーレイは自作マーカー1つでしたが、今回から自作マーカー2つ、ポリライン1つと複数になりました。それぞれのオーバーレイを描画するためにChildren.mapchildrenを展開します。
    Childrenを使うと、propsであるchildren から受け取ったJSXを操作、変換することができます。

    また、前回は<map>内でオーバーレイ(自作マーカー)の描画を制御していませんでしたが、今回は二項演算子で描画を制御することになったので、<content>側でもchildrenを描画する条件を加えます。
    isValidElement()を使用すると、値がReact要素であるかどうかを判定することができるので、これを活用します。
    isValidElement

    // Map.tsx
    
    // ..省略
    
    const Content: FC<{  
      // ..省略
    
      return (
        <div style={VIEW_STYLE} ref={ref}>
          {/* {cloneElement(children as ReactElement, { map })} 削除 */}
    	    
          {/* 追加 */}
          {Children.map(children, (child) => { // Children.mapで複数のオーバーレイを展開する。
    
            // React要素のchildrenが存在してなければ何も描画しない。
            if (!isValidElement(child)) return;
            return cloneElement(child as ReactElement, { map });
          })}
        </div>
      );
    };
    
    export default Map;

    これでクリックした2点の位置にマーカーが表示されるようになりました。クリックを3回するとマーカーが消えるのも確認できます。

    クリックした2点の間にラインを描画する

    ここまで来れば、冒頭に作成した<PolyLine>に2点のクリックした位置をpathとして渡すだけです。
    <Map>に戻り、クリックした2点の位置をpolylinePathとしてまとめておきます。後ほど、polylinePathが存在していたら<PolyLine>を描画するように制御したいため、条件式を記載しておきます。

    // Map.tsx
    
    // ...省略
    
    const Map: FC = () => {
      // ...省略
    	
      // 追加
      // クリックした2つの位置を配列にまとめて、後々<PolyLine>に渡す。
      const polylinePath = useMemo(() => {
        // 2点がクリックされていなければundefinedを返す。
        if (!centerPlace?.geometry?.location || !firstPlace?.geometry?.location) {
          return undefined;
        }
        
        // 2点の位置を配列にして返す。
        return [centerPlace.geometry.location, firstPlace.geometry.location];
      }, [centerPlace, firstPlace]);
      
      // ...省略
    };
    
    // ...省略

    最後に<PolyLine>polylinePathをpropsとして渡して、二項演算子でレンダーを制御します。

    // Map.tsx
    
    // ...省略
    
    const Map: FC = () => {
      // ...省略
      return (
        <Wrapper apiKey={MAP_API_KEY}>
          <Content onClickMapCallback={onClickMapCallback}>
            {centerPlace && <Marker place={centerPlace} />}
            {firstSacredPlace && <Marker place={firstSacredPlace} />}        
            {polylinePath && <PolyLine path={polylinePath} />} {/* 追加 */}
          </Content>
        </Wrapper>
      );
    };
    
    // ...省略

    これでクリックした2点の間にラインを描画することができました。

    完成コード

    // 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",
    };
    
    export const Marker: FC<{
      // propsを追加
      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>
              </div>
            </OverlayView>
          )}
        </>
      );
    };
    
    // PolyLine.tsx
    import { FC, useEffect, useState } from "react";
    
    export const PolyLine: FC<{
      path: (google.maps.LatLng | google.maps.LatLngLiteral)[];
      map?: google.maps.Map;
    }> = ({ path, map }) => {
      const [polyline, setPolyline] = useState<google.maps.Polyline>();
    
      useEffect(() => {
        if (!polyline) {
          setPolyline(new google.maps.Polyline());
        }
    
        return () => {
          if (polyline) {
            polyline.setMap(null);
          }
        };
      }, [polyline]);
    
      useEffect(() => {
        if (map && polyline) {
          polyline.setOptions({
            map: map, // 描画するGoogle Mapの指定
            path, // ラインを描画する位置
            strokeWeight: 4, // 線の太さ
            strokeColor: "black", // 線の色
            strokeOpacity: 1, // 線の透明度
          });
        }
      }, [map, path, polyline]);
    
      return null;
    };
    
    // Map.tsx
    import React, {
      Children,
      FC,
      ReactElement,
      ReactNode,
      cloneElement,
      isValidElement,
      useCallback,
      useEffect,
      useMemo,
      useRef,
      useState,
    } from "react";
    import { Wrapper } from "@googlemaps/react-wrapper";
    import { Marker } from "./Marker";
    import { PolyLine } from "./PolyLine";
    
    const MAP_API_KEY = "xxxx"; // 第1弾の記事で生成したAPIキー
    
    // 最初に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 [centerPlace, setCenterPlace] = useState<google.maps.places.PlaceResult>();
      const [firstPlace, setFirstPlace] = useState<google.maps.places.PlaceResult>();
    
      const setPlaces = useCallback(
        (place: google.maps.places.PlaceResult) => {
          if (!centerPlace) {
            // 1回目のクリック時の処理
            setCenterPlace(place);
          } else if (!firstPlace) {
            // 2回目のクリック時の処理
            setFirstPlace(place);
          } else {
            // 3回目のクリック時の処理、2つのStateを初期化
            setCenterPlace(undefined);
            setFirstPlace(undefined);
          }
        },
        [centerPlace, firstPlace]
      );
    
      const onClickMapCallback = useCallback(
        (position: google.maps.LatLng) => {
          // Google Mapの座標を受け取り、setPlacesでStateを更新する。
          setPlaces({
            geometry: {
              location: position,
            },
          });
        },
        [setPlaces]
      );
    
      // クリックした2つの位置を配列にまとめて、後々<PolyLine>に渡す。
      const polylinePath = useMemo(() => {
        if (!centerPlace?.geometry?.location || !firstPlace?.geometry?.location) {
          return undefined;
        }
        return [centerPlace.geometry.location, firstPlace.geometry.location];
      }, [centerPlace, firstPlace]);
    
      return (
        <Wrapper apiKey={MAP_API_KEY}>
          <Content onClickMapCallback={onClickMapCallback}>
            {centerPlace && <Marker place={centerPlace} />}
            {firstPlace && <Marker place={firstPlace} />}
            {polylinePath && <PolyLine path={polylinePath} />}
          </Content>
        </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;

    まとめ

    今回はGoogle Map上のクリックした2点の間にラインを描画させました。
    次回はいよいよ、今まで作成した処理を使い五芒星を描画する処理を書いていきます。

    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上に五芒星を描画する@googlemaps/react-wrapperでGoogle Map上に自作マーカーを描画する