🗾

【React】Google Map Api を使ってマップに現在位置を表示させてみた

2025/01/03に公開

はじめに

半年くらい前に、Google Map Apiを使って、現在地を常時監視したり、複数の店舗をピンで表示するものを作りました。ほんとはロジックをhooksなどに切り分けるべきですが、1個の説明が一つのコードブロックに収まるようリファクタリング前の思いのまま書いた状態になっています。
また、APIキーの隠蔽など課題も残っています。ご了承ください。。。
結構複雑で自分でも整理しきれてない部分もあるので、ご指摘などいただけると嬉しいです。

ReactでGoogleMapを使うための公式Wrapperライブラリ

@googlemaps/react-wrapper

https://github.com/googlemaps/react-wrapper

インストール

pnpm add @googlemaps/react-wrapper

TypeScript用の型定義をインストール

pnpm add -D @types/google-maps

Wrapperの使い方

map読み込みのステータスに応じて表示するコンポーネントを設定する

import { Wrapper } from "@googlemaps/react-wrapper";

// マップの読み込みステータスに応じた表示を設定
const render = (status: Status) => {
  switch (status) {
    case Status.LOADING:
      return <div>loading...</div>;
    case Status.FAILURE:
      return <div>fail...</div>;
    case Status.SUCCESS:
      return <MyMap />;
  }
};

function App() {
  return 
    <Wrapper apiKey="YOUR_API_KEY" render={render} />
  );
}

export default App;

Mapコンポーネントに地図を表示

  • useStateでmap変数を管理する
  • useRefでマップを表示させるエレメントを指定する
  • useEffectでマップを初期化する
  • unmountする時に忘れずにマップを初期化
const MyMap = () => {
  const [map, setMap] = useState<google.maps.Map | null>();
  const mapElement = useRef<HTMLDivElement>(null);
  
  const cleanUpMap = useCallback(() => {
    if (map) {
      setMap(null);
    }
  }, [map]);
  
  useEffect(() => {
	  // mapを初期化
    if (mapElement.current) {
      setMap(
        new window.google.maps.Map(mapElement.current, {
          center: { lat: 35.123456, lng: 139.123456 },
          gestureHandling: "greedy", //一本指で地図を移動できるモード
          zoom: 15,
        })
      );
    }
    return cleanUpMap;
  }, []);

  return (
    <>
      <div ref={mapElement} style={{ width: "100vw", height: "100vh" }} />
    </>
  );
};

一本指で地図を移動できるモード

アプリみたいに動かせるモード(状況に応じて)

地図初期化時に

setMap(
        new window.google.maps.Map(mapElement.current, {
          // ...初期設定
          gestureHandling: "greedy", //一本指で地図を移動できるモード
        })
      );

を設定。

現在地を中心に地図を表示

現在地の取得

JavaScriptの標準APIの navigator.geolocation を使用する

navigator.geolocation.getCurrentPosition(成功時のcallback, エラーのcallback);

// 成功時のcallbackには引数として位置情報が渡される。

Navigator: geolocation プロパティ - Web API | MDN

成功時のcallbackには引数として位置情報が渡される。

成功時に渡される引数: GeolocationPosition = {
  coords: {
    accuracy: 緯度経度の制度,
    altitude: 標高・海抜,
    altitudeAccuracy: 標高の制度,
    heading: 向いている方向,
    latitude: 緯度,
    longitude: 経度,
    speed: 移動速度
  },
  timestamp: 位置情報が取得された時刻
}

例: 現在地の経度を取得

navigator.geolocation.getCurrentPosition(pos => console.log(pos.coords.latitude));
// => 現在地のlatitude(緯度) 35.123456...

https://developer.mozilla.org/ja/docs/Web/API/GeolocationPosition

地図初期化の時に現在地を渡す

const MyMap = () => {
  const [map, setMap] = useState<google.maps.Map | null>();
  const mapElement = useRef<HTMLDivElement>(null);

  // 地図の中心を指定してGoogle Mapを初期化する関数を定義
  const initMap = useCallback((centerPosition: google.maps.LatLngLiteral) => {
    if (mapElement.current) {
      setMap(
        new window.google.maps.Map(mapElement.current, {
          center: centerPosition, // ここに取得した現在地が入る
          gestureHandling: "greedy",
          zoom: 15,
          mapId: "my-map", // 高度なマーカーを使うのに必須
        })
      );
    }
  }, []);

  const cleanUpMap = useCallback(() => {
    if (map) {
      setMap(null);
    }
  }, [map]);
  
  // 位置情報取得できないときのcallback
  const showMapWithDefaultPosition = useCallback(() => {
	  const defaultPosition = { lat: 35.123456, lng: 139.123456 }
    const initPosition: google.maps.LatLngLiteral = defaultPosition;
    initMap(initPosition); 
  }, [initMap]);

  useEffect(() => {
    if (navigator.geolocation) { // デバイスが位置情報に対応しているか確認
      // 現在位置を取得して地図を初期化
      // 位置情報が取得できない時などは、あらかじめ指定した場所を中心に表示するようにして地図を初期化
      navigator.geolocation.getCurrentPosition((pos: GeolocationPosition) => {
        const currentPos: google.maps.LatLngLiteral = { lat: pos.coords.latitude, lng: pos.coords.longitude };
        initMap(currentPos);
      }, showMapWithDefaultPosition);
    } else {
	    showMapWithInitPosition(); // 位置情報非対応の場合
    }
    return cleanUpMap;
  }, []);

  return (
    <>
      <div ref={mapElement} style={{ width: "100vw", height: "100vh" }} />
    </>
  );
};

現在地アイコンで現在位置を常時表示

現在地を監視し続けるAPI

navigator.geolocation.watchPosition()

https://developer.mozilla.org/ja/docs/Web/API/Geolocation/watchPosition

マーカーを設置

Wrapperコンポーネントに高度なマーカーを使うための設定を追加する

version="beta" と libraries={["marker"]} を追加

// App.tsx

import { Status, Wrapper } from "@googlemaps/react-wrapper";
import "./App.css";
import { MyMap } from "./components/MyMap";

const render = (status: Status) => {
  switch (status) {
    case Status.LOADING:
      return <div>loading...</div>;
    case Status.FAILURE:
      return <div>fail...</div>;
    case Status.SUCCESS:
      return <MyMap />;
  }
};

function App() {
  return (
    <Wrapper
      apiKey="YOUR_API_KEY"
	  render={render}
      version="beta" // 追加
      libraries={["marker"]} // 追加
    />
  );
}
export default App;
  • マーカーコンポーネントを生成し、mapエレメントにchildrenとしてを渡す
// MyMap.tsx
import { CurrentPositionMarker } from '../path/to/component'

const MyMap = () => {
  // 上記、マップの初期化処理
  return (
    <div ref={mapElement} style={{ width: "100vw", height: "100vh" }}>
      <CurrentPositionMarker /> // これを追加
    </div>
  )
}

現在地を表示するマーカーコンポーネント

// CurrentPositionMarker.tsx

export const CurrentPositionMarker = ({ map }: { map: google.maps.Map | null }) => {
  const markerRef = useRef<google.maps.marker.AdvancedMarkerElement | null>();

  // useStateで現在地を監視
  const [currentPos, setCurrentPos] = useState<google.maps.LatLngLiteral | null>();
  useEffect(() => {
    if (navigator.geolocation) {
      navigator.geolocation.watchPosition((pos) => {
        setCurrentPos({ lat: pos.coords.latitude, lng: pos.coords.longitude });
      });
    } else {
      console.log("位置情報非対応デバイスです");
    }
    return setCurrentPos(null);
  }, []);

  // 現在地マーカーを生成
  useEffect(() => {
    // 現在地マーカーの画像要素を生成
    const currentPosMarkerImg = document.createElement("img");
    currentPosMarkerImg.src = "/currentPosMarker.svg";

    // マーカーエレメントを生成
    markerRef.current = new google.maps.marker.AdvancedMarkerElement({
      map,
      content: currentPosMarkerImg,
      zIndex: 5, // 他のマーカーと重なっても現在地が上に表示されるように少し高めに設定
    });

    // 先に現在位置にマーカー作って、あとから現在位置を更新したほうが表示が早かった。
    if (navigator.geolocation) {
      navigator.geolocation.getCurrentPosition((pos) => {
        if (markerRef.current) {
          markerRef.current.position = {
            lat: pos.coords.latitude,
            lng: pos.coords.latitude,
          };
        }
      });
    }

    return () => {
      markerRef.current = null;
    };
  }, [map]);

  // 現在地を更新
  useEffect(() => {
    if (markerRef.current) {
      markerRef.current.position = currentPos;
    }
  }, [currentPos]);
  return null;
};

最初、watchPositionで現在位置を取得してから、マーカーを作れば一回で現在地にマーカーを表示できると思ったんですが、現在地アイコンが表示されるのにタイムラグができてしまったので、最初に現在地をgetCurrentPositionで取得してから、現在地を更新していく処理に分割しました。

【参考】

ReactでGoogleMapsAPIを使いこなす|高度なマーカーの作成

https://www.ecomottblog.com/?p=11754

画像を使ったマーカーを作成する(高度なマーカー)

https://developers.google.com/maps/documentation/javascript/advanced-markers/graphic-markers?hl=ja

現在地マーカーのアニメーション【参考】

Google Mapのアプリのような現在地マーカーはSVGのアニメーションで作りました。
SVGのアニメーションもなかなかおもしろかったので、別途記事を書きたいと思います。

<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40" width="40" height="40">
    <circle
        cx="50%"
        cy="50%"
        r="25%"
        class="circle"
        fill="#55f"
        stroke="#FFF"
        stroke-width="2"
        filter="drop-shadow(0 0 1px #888)"
    >
        <animate
            attributeName="r"
            values="22%;25%;22%"
            dur="2500ms"
            repeatCount="indefinite"
        />
        <animate
            attributeName="stroke-width"
            values="3;2;3"
            dur="2500ms"
            repeatCount="indefinite"
        />
    </circle>
</svg>

店舗一覧などのマーカーを作成

マーカーの設置

表示したいマーカーの情報を取得(ここでは配列で定義)します。

そして、先程追加した現在地マーカーコンポーネント(CurrentPositionMarker)の下に
任意の場所のマーカーコンポーネントを配置。(特に意味はないですが、劇団四季の劇場を例に)

import { type FC, useCallback, useEffect, useRef, useState } from "react";

export const MyMap = () => {
  // 上記同様の処理
	
  // マーカーを設置したい場所を便宜的に定義
  const theaters = [
    {
      theaterName: "JR東日本四季劇場[春][秋]",
      zipCode: "〒105-0022",
      address: "東京都港区海岸1-10-45",
      position: {
        lat: 35.65689267403625,
        lng: 139.76185983559932,
      },
    },
    {
      theaterName: "有明四季劇場",
      zipCode: "〒135-0063",
      address: "東京都江東区有明2丁目1-29",
      position: {
        lat: 35.63921995769103,
        lng: 139.7952811412348,
      },
    },
    {
      theaterName: "電通四季劇場[海]",
      zipCode: "〒105-0021",
      address: "東京都港区東新橋1-8-2",
      position: {
        lat: 35.66490079957247,
        lng: 139.76249532485588,
      },
    },
  ];

  return (
    <>
      <div ref={mapElement} style={{ width: "100vw", height: "100vh" }}>
        <CurrentPositionMarker map={map} />
        {theaters.map((theater: Theater) => (
          <TheaterMarker map={map} theater={theater} key={theater.theaterName} />
        ))}
      </div>
    </>
  );
}

劇場のマーカーコンポーネントを作成

// TheaterMarker.tsx

// 任意の場所に設置するマーカーの型を定義
export interface Theater {
  theaterName: string;
  zipCode: string;
  address: string;
  position: google.maps.LatLngLiteral;
}
interface TheaterMarkerProps {
  map: google.maps.Map | null;
  theater: Theater;
}

// 任意の場所にマーカーを設置(マーカーコンポーネント)
export const TheaterMarker: FC<TheaterMarkerProps> = ({ map, theater }) => {
  const theaterMarker = useRef<google.maps.marker.AdvancedMarkerElement | null>(null);
  useEffect(() => {
    // すでにマーカーがある時は再作成しない
    if (theaterMarker.current) {
      theaterMarker.current.position = theater.position;
      return;
    }
    
    // マーカーピンを作成
    const theaterPin = document.createElement("img");
    theaterPin.src = "/theater.png";
    theaterMarker.current = new google.maps.marker.AdvancedMarkerElement({
      map,
      content: theaterPin,
      title: theater.theaterName,
      position: theater.position,
      zIndex: 3,
    });

    return () => {
      // コンポーネントがアンマウントされた時にマーカーを削除
      if (theaterMarker.current) {
        theaterMarker.current.map = null;
        theaterMarker.current = null;
      }
    };
  }, [map, theater]);
  return null;
};

※clean up関数を定義しないとピンが再レンダリングのたびに何重にも表示されてしまう。

マーカーにクリックイベントなどを追加

クリック時に他に開いているinfoWindowを閉じるため、MyMapコンポーネントでinfoWindowsを定義し、TheaterMarkerコンポーネントに渡しておく。

export const MyMap = () => {
// ...

const infoWindows = useRef<google.maps.InfoWindow[]>([]); // ここを追加
return (
    <>
      <div ref={mapElement} style={{ width: "100vw", height: "100vh" }}>
        <CurrentPositionMarker map={map} />
        {theaters.map((theater: Theater) => (
          <TheaterMarker map={map} 
	          theater={theater} 
		        key={theater.theaterName}
		        infoWindows={infoWindows} // ここを追加
		       />
        ))}
      </div>
    </>
  );
 }

https://developers.google.com/maps/documentation/javascript/events?hl=ja

https://developers.google.com/maps/documentation/javascript/advanced-markers/accessible-markers?hl=ja

クリックした際にマップの中央に移動し、infoWindowを開く

// TheaterMarker.tsx

// マーカーピンを作成
// ...

// クリックイベントを設定
theaterMarker.current.addListener("gmp-click", () => {
  // pinを地図のセンターに
  if (!map) return;
  if (!theaterMarker.current?.position) return;
  map.panTo(theaterMarker.current.position);
  map.setZoom(15);
  map.panBy(0, 1); // panToとzoomを同時行うと至近距離の場合、アニメーションがスキップされるが、panByを入れることでスムーズに移動する。

  // 現在開いているinfoWindowを閉じて、新しいinfoWindowを開く
  for (const infoWindow of infoWindows.current) infoWindow.close();
  const newInfoWindow = new google.maps.InfoWindow();
  newInfoWindow.setContent(`
      <div style="color: red">${theater.theaterName}</div>
    `);
  newInfoWindow.open(theaterMarker.current.map, theaterMarker.current);
  infoWindows.current = [newInfoWindow];
});

// return (...

Discussion