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

@googlemaps/react-wrapperでGoogle Map上に自作マーカーを描画する

    目次

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

    『[シリーズ連載] @googlemaps/react-wrapperで自作マーカーと自作ポリラインを描画する』の第2弾の記事になります。
    前回の記事はこちら→https://techlab.q-co.jp/articles/88/
    前回は、Google Maps Platformが提供している、ReactでGoogle Mapを表示できる@googlemaps/react-wrapperの解説と開発準備について紹介をして、ReactでGoogle Mapを表示するところまで進めました。
    今回の記事では、Google Map上に自作マーカーを描画させる方法を紹介します。

    完成形はこちらになります。真ん中に赤いxマークを描画しています。
    このマークはGoogle Mapをドラッグしたり、ズームして動かしてもマークも連動して動きます。

    環境

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

    自作マーカーを作成

    <div>にスタイルを当てて、自作マーカーを作成します。
    今回はわかりやすく赤文字のxをマーカーにします。

    // Marker.tsx
    import { CSSProperties, FC } from "react";
    
    // マーカーにスタイルを当てる
    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 = () => {
      return (
        <>
          <div style={wrapperStyle}>
            <span style={iconStyle}>x</span>
          </div>
        </>
      );
    };
    

    google.maps.OverlayViewクラスを宣言する

    Maps JavaScript APIではgoogle.maps.OverlayViewクラスを使用して、ポイント、ライン、領域のオブジェクト、またはそれらのオブジェクトの集合を指定してGoogle Map上に描画することができます。これらのオブジェクトはオーバーレイと呼んでいます。

    今回の場合は、自作マーカーがオーバーレイとなります。
    オーバーレイはGoogle Mapの緯度と経度で表される座標に紐付けて描画することができます。座標に紐付けることでGoogle Mapをズームさせたり、ドラッグして動かすと連動してオーバーレイも動くようになります。

    google.maps.OverlayViewクラスを使用する際は、onAdd()draw()onRemove() の 3 つのメソッドを実装する必要があります。
    これらのメソッドは、google.maps.OverlayViewクラスのsetMap()の実行をきっかけに自動で実行されます。setMap()は、自作オーバーレイを描画するGoogle Mapを指定するメソッドです。

    オーバーレイを描画する処理の流れをまとめると、下記のようになります。

    1. setMap(map)でオーバーレイを描画するGoogle Mapを指定する。
    2. setMap(map)が実行されると、onAdd()が実行されてオーバーレイが作成される。
    3. onAdd()が実行されると、draw()が実行されてオーバーレイが描画または更新される。
    4. setMap(null)でGoogle Mapの指定が削除されると、onRemove()が実行されてオーバーレイも削除される。 

    google.maps.OverlayViewクラスを継承した独自のクラスを作りながら、それぞれのメソッドについて解説していきます。

    google.maps.OverlayViewクラスの継承

    google.maps.OverlayViewクラスを継承したクラスを作成する関数createOverlay()を宣言します。
    引数には下記の3つを指定します。

    1. container: HTMLElement
      google.maps.OverlayViewクラスを継承したクラスでインスタンス化した子ノードを格納する<div>を受け取ります。divを取得するので型はHTMLElementになります。
    2. pane: keyof google.maps.MapPanes
      ペイン(pane)は DOM ツリーのノードを意味しています。ペインを指定することでオブジェクトを描画するレイヤーの順番を指定できます。
      google.maps.MapPanesでは、
      floatPane > overlayMouseTarget > markerLayer > overlayLayermapPane
      の順番でオブジェクトを描画するレイヤの位置を選択できます。
      例えば、オーバーレイAをpane: "floatPane"と、オーバーレイBをpane: "overlayMouseTarget"と指定すれば、オーバーレイAはオーバーレイBの上に描画されます。
      正しく選択できるように、型はkey of google.maps.MapPanesとします。
    3. position: google.maps.LatLng | google.maps.LatLngLiteral
      Google Mapの緯度と経度で表される座標を受け取ります。取得した座標をスタイルに当てることで、オーバーレイの描画位置を決定します。
      型はGoogle Mapの緯度と経度に合わせるためgoogle.maps.LatLnggoogle.maps.LatLngLiteralにします。
    // Overlay.ts
    export const createOverlay = (
      container: HTMLElement,
      pane: keyof google.maps.MapPanes,
      position: google.maps.LatLng | google.maps.LatLngLiteral
    ) => {
      // 処理
    }
    

    続いて、google.maps.OverlayViewクラスを継承したクラスを関数内で宣言します。
    指定した引数をコンストラクタの初期値になるようにパラメータを設定します。コンストラクタ内で親クラスのgoogle.maps.OverlayViewクラスのメソッドを呼び出すために、キーワードsuper()も記載します。
    super

    // Overlay.ts
    export const createOverlay = (
      container: HTMLElement,
      pane: keyof google.maps.MapPanes,
      position: google.maps.LatLng | google.maps.LatLngLiteral
    ) => {
      // 追加
      class Overlay extends google.maps.OverlayView {
        container: HTMLElement;
        pane: keyof google.maps.MapPanes;
        position: google.maps.LatLng | google.maps.LatLngLiteral;
    
        constructor(
          container: HTMLElement,
          pane: keyof google.maps.MapPanes,
          position: google.maps.LatLng | google.maps.LatLngLiteral
        ) {
          //親クラスのメソッドを使用するために記載
          super();
    
          // 初期値を設定
          this.container = container;
          this.pane = pane;
          this.position = position;
        }
      }
    }
    

    続いて、google.maps.OverlayViewクラスに必須の3つのメソッドを実装していきます。

    onAdd()を実装する

    onAdd()メソッドは、setMap()が呼び出された後に1回呼び出されます。オーバーレイのDOM 要素を初期化して、新しくオーバーレイを指定したペインの位置に作成・追加をします。
    getPanes()でペインを指定して、appendChild()でオーバーレイをDOMに追加します。

    // Overlay.ts
    export const createOverlay = (
      // ...省略
        this.position = position;
      }
    
        // 追加
        onAdd(): void {
          // ペイン(≒レイヤー)の位置を指定
          const pane = this.getPanes()?.[this.pane];
      
          // 指定したペインにDOM要素(<div>)を追加する
          pane?.appendChild(this.container);
        }
      }
    }
    

    draw()を実装する

    draw()メソッドは、onAdd()メソッドで追加したオーバーレイを描画または更新します。このメソッドはonAdd()の後に呼び出され、Google Mapがズームまたは中心が変化すると呼び出されます。
    projection.fromLatLngToDivPixel()でGoogle Map上の緯度と経度を取得して、それらの値をcontainer.style.transformtranslateに当てることで描画する位置を決定します。

    // Overlay.ts
    export const createOverlay = (
      // ...省略
        this.position = position;
      }
    
        onAdd(): void {
          const pane = this.getPanes()?.[this.pane];
          pane?.appendChild(this.container);
        }
    
        // 追加
        draw(): void {
          // getProjection()はonAdd()が呼び出されると初期化される。
          const projection = this.getProjection();
    
          // fromLatLngToDivPixel()でピクセルからGoogle Map上の経度と緯度を取得する。
          const point = projection.fromLatLngToDivPixel(this.position);
    
          // 経度と緯度が取得できなかった場合はretrunを返してエラーを防止します。
          if (point === null) {
            return;
          }
    
          // pointで取得した経度と緯度をスタイルに当てて、オーバーレイの描画位置を決定する。
          this.container.style.transform = `translate(${point.x}px, ${point.y}px)`;
        }
      }
    }
    

    onRemove()を実装する

    onRemove()メソッドはオーバーレイをDOMから削除します。このメソッドは、Google Mapを無効にするsetMap(null)が呼び出された後に1回呼び出されます。Google Mapの更新や非表示の際にオーバーレイも連動して画面から消すために必要です。
    container.parentNodeで親ノードを宣言して、親ノードの存在が確認できていればremoveChild()でオーバーレイをDOMから削除します。

    // Overlay.ts
    export const createOverlay = (
      // ...省略
      
        draw(): void {
          const projection = this.getProjection();
          const point = projection.fromLatLngToDivPixel(this.position);
    
          if (point === null) {
            return;
          }
          this.container.style.transform = `translate(${point.x}px, ${point.y}px)`;
        }
    
        // 追加
        onRemove(): void {
          // parentNodeが存在していたら実行します。
          if (this.container.parentNode !== null) {
            // マーカーをDOMから取り除きます。
            this.container.parentNode.removeChild(this.container);
          }
        }
      }
    }
    

    インスタンスを戻り値に指定する

    google.maps.OverlayViewクラスに必須の3つのメソッドを実装できました。
    最後に、インスタンスを戻り値に指定します。

    // Overlay.ts
    export const createOverlay = (
      // ...省略
    
      onRemove(): void {
          // parentNodeが存在していたら実行します。
          if (this.container.parentNode !== null) {
            // マーカーをDOMから取り除きます。
            this.container.parentNode.removeChild(this.container);
          }
        }
      }
      // 追加
      // 戻り値にインスタンスを指定します。
      return new Overlay(container, pane, position);
    }
    

    次は、先程作成した関数createOverlay()を使用してインスタンス化したコンポーネントを作成します。

    インスタンス化したコンポーネントを作成する

    インスタンス化したコンポーネントを作成するOverlayView()を宣言します。
    propsには下記4つを指定します。

    1. position: google.maps.LatLng | google.maps.LatLngLiteral;
      オーバーレイの描画位置を受け取ります。
      型はcreateOverlay()と合わせるためにgoogle.maps.LatLnggoogle.maps.LatLngLiteralにします。
    2. pane?: keyof google.maps.MapPanes
      オブジェクトを描画するレイヤーの順番を取得します。今回はオーバーレイであるマーカーを一番上のレイヤーに描画したいので"floatPane"を初期値にします。
      初期値を指定しているのでundefinedを許容します。
      型はkey of google.maps.MapPanesとします。
    3. map: google.maps.Map
      オーバーレイを反映させるGoogle Mapを受け取ります。
      型はgoogle.maps.Mapとします。
    4. children: ReactNode
      Google Mapに描画するオーバーレイコンポーネント(今回の場合は自作マーカー)をchildrenとして受け取ります。
    // OverlayView.tsx
    export const OverlayView: FC<{
      position: google.maps.LatLng | google.maps.LatLngLiteral;
      pane?: keyof google.maps.MapPanes;
      map: google.maps.Map;
      children: ReactNode;
    }> = ({ position, pane = "floatPane", map, children }) => {
      // 処理
    };
    

    続いて、createOverlay()に渡す<div>を作成します。この定数は再生成の必要がないので、依存関係なしでメモ化をしておきます。

    // OverlayView.tsx
    export const OverlayView: FC<{
      // ...省略
    
      // 追加
      const container = useMemo(() => document.createElement("div"),[])
    };
    
    

    containerと引数に指定したpanepositionを、createOverlay()に渡してオーバーレイを作成します。オーバーレイは一度作成したら削除されるまでは再生成の必要がないので、メモ化しておきます。

    // OverlayView.tsx
    export const OverlayView: FC<{
      // ...省略
    
      // 追加
      // createOverlay()でオーバーレイを作成する。
      const overlay = useMemo(() => 
        createOverlay(container, pane, position),
        [container, pane, position]
      );
    };
    
    

    次に、useEffect()を使ってsetMap()を発火します。
    マウント時には、setMap(map)を呼び出してオーバーレイを描画するGoogle Mapを指定します。setMap(map)が呼び出されるとonAdd()が呼び出され、オーバーレイが作成されます。
    アンマウント時にはsetMap(null)を呼び出してmapを削除します。同時にonRemove()が呼び出され、オーバーレイも削除されます。

    // OverlayView.tsx
    
    export const OverlayView: FC<{
      // ...省略
    
      // 追加
      useEffect(() => {
        // マウント時にオーバーレイを描画したいGoogle Mapを指定する。
        overlay?.setMap(map);
    
        // アンマウント時にGoogle Mapの指定を削除する。
        return () => overlay?.setMap(null); 
      }, [map, overlay]);
    };
    
    

    オーバーレイをGoogle Mapの中にレンダーさせるためにcreatePortal(children, domNode, key?)を使用します。
    createPortal()を使うとDOM上の別の場所に子要素をレンダーすることができます。第2引数でレンダーする場所を指定できるので、Google Mapと同じ場所のcontainerを指定します。
    Google Mapと同じ場所にレンダーすることで、Google Map内でReactコンポーネントを独立させながら、座標などのGoogle Mapの情報と連動させることができます。

    // OverlayView.tsx
    
    export const OverlayView: FC<{
      // ...省略
    
      // 追加
      // createPortal()を使いGoogle Mapと同じ場所にchildrenをレンダーする。
      return createPortal(children, container);
    };
    
    

    画面上のGoogle Mapに自作マーカーを描画させる

    上記で宣言した<OverlayView>に自作マーカーをchildrenとして渡して、Google Map上に描画できるオーバーレイにします。
    propsには、自作マーカーを描画する場所を示すplace: google.maps.LatLngLiteralと描画するGoogle Mapを指定するmap?: google.maps.Mapを追加します。後ほど説明しますが、mapcloneElement()を使い間接的に渡すためundefinedを許容しておきます。
    自作マーカーはGoogle Mapを指定している時だけ描画するので、二項演算子{map && ~}で記載します。

    // Marker.tsx
    
    // ...省略
    
    export const Marker: FC<{
      // propsを追加
      place: google.maps.LatLngLiteral;
      map?: google.maps.Map;
    }> = ({ place, map }) => {
      return (
        <>
          {/* 追加 */}
          {/* 二項演算子でmapが存在している時だけ自作マーカーを描画する。 */}
          {map && (
            // 自作マーカーを<OverlayView>に渡してオーバーレイにする。
            <OverlayView position={place} map={map}>
              <div style={wrapperStyle}>
                <span style={iconStyle}>x</span>
              </div>
            </OverlayView>
          )}
        </>
      );
    };
    

    Map.tsx<content>childrenを渡せるように調整して、Google Mapと自作マーカーの両方を描画できるようにします。cloneElement()を使用して、childrenmapをpropsとして渡します。

    // Map.tsx
    
    // ...省略
    
    const Content: FC<{
      // 追加
      children: ReactNode;
    }> = ({ 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));
        }
      }, []);
    
      return (
        <div style={VIEW_STYLE} ref={ref}>
           {/* 追加 */}
           {cloneElement(children as ReactElement, { map })}
        </div>
      );
    };
    

    <Map>の中の<content>に、childrenとして<Marker>を渡します。自作マーカーの描画場所は、今回はお試しで描画しているGoogle Mapと同じにしておきます。propsのplaceにはDEFAULT.CENTERを渡します。

    // Map.tsx
    
    // ...省略
    
    const DEFAULT = {
      CENTER: {
        lat: 35.6973225,
        lng: 139.8265658,
      } as google.maps.LatLngLiteral,
      ZOOM: 16,
    } as const;
    
    // ...省略
    
    const Map = () => {
      return (
        <Wrapper apiKey={MAP_API_KEY}>
          {/* 追加 */}
          <Content>
            <Marker place={DEFAULT.CENTER} />
          </Content>
        </Wrapper>
      );
    };
    
    // ...省略

    無事にGoogle Mapに自作マーカーが描画されていれば成功です。

    完成コード

    // Overlay.ts
    export const createOverlay = (
      container: HTMLElement,
      pane: keyof google.maps.MapPanes,
      position: google.maps.LatLng | google.maps.LatLngLiteral
    ) => {
      class Overlay extends google.maps.OverlayView {
        container: HTMLElement;
        pane: keyof google.maps.MapPanes;
        position: google.maps.LatLng | google.maps.LatLngLiteral;
    
        constructor(
          container: HTMLElement,
          pane: keyof google.maps.MapPanes,
          position: google.maps.LatLng | google.maps.LatLngLiteral
        ) {
          super();
          this.container = container;
          this.pane = pane;
          this.position = position;
        }
    
        onAdd(): void {
          const pane = this.getPanes()?.[this.pane];
          pane?.appendChild(this.container);
        }
    
        draw(): void {
          const projection = this.getProjection();
          const point = projection.fromLatLngToDivPixel(this.position);
    
          if (point === null) {
            return;
          }
          this.container.style.transform = `translate(${point.x}px, ${point.y}px)`;
        }
    
        onRemove(): void {
          if (this.container.parentNode !== null) {
            this.container.parentNode.removeChild(this.container);
          }
        }
      }
    
      return new Overlay(container, pane, position);
    };
    
    // OverlayView.tsx
    import { FC, ReactNode, useEffect, useMemo } from "react";
    import { createOverlay } from "./Overlay";
    import { createPortal } from "react-dom";
    
    export const OverlayView: FC<{
      position: google.maps.LatLng | google.maps.LatLngLiteral;
      pane?: keyof google.maps.MapPanes;
      map: google.maps.Map;
      children: ReactNode;
    }> = ({ position, pane = "floatPane", map, children }) => {
      const container = useMemo(() => document.createElement("div"), []);
    
      const overlay = useMemo(
        () => createOverlay(container, pane, position),
        [container, pane, position]
      );
    
      useEffect(() => {
        overlay?.setMap(map);
        return () => overlay?.setMap(null);
      }, [map, overlay]);
    
      return createPortal(children, container);
    };
    
    
    // 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.LatLngLiteral;
      map?: google.maps.Map;
    }> = ({ place, map }) => {
      return (
        <>
          {map && (
            <OverlayView position={place} map={map}>
              <div style={wrapperStyle}>
                <span style={iconStyle}>x</span>
              </div>
            </OverlayView>
          )}
        </>
      );
    };
    
    // Map.tsx
    import React, {
      FC,
      ReactElement,
      ReactNode,
      cloneElement,
      useEffect,
      useRef,
      useState,
    } from "react";
    import { Wrapper } from "@googlemaps/react-wrapper";
    import { Marker } from "./Marker";
    
    const MAP_API_KEY = 'xxxx'; // 前回の記事で生成した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: React.FC = () => {
      return (
        <Wrapper apiKey={MAP_API_KEY}>
          <Content>
            <Marker place={DEFAULT.CENTER} />
          </Content>
        </Wrapper>
      );
    };
    
    const Content: FC<{
      children: ReactNode;
    }> = ({ 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));
        }
      }, []);
    
      return (
        <div style={VIEW_STYLE} ref={ref}>
          {cloneElement(children as ReactElement, { map })}
        </div>
      );
    };
    
    export default Map;
    
    

    まとめ

    今回は自作マーカーをGoogle Map上に描画しました。
    次回はラインを描画するAPIのPolylineを使用して、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上をクリックした2点の間にラインを描画するAdobe Animate CCとPixiJSを連携するプラグインの紹介【pixi-animate-container】