Amazon Location Serviceとreact-map-glを使って地図を表示してみる

# はじめに
最近、Amazon Location Serviceを勉強し始めたので、Reactの地図ライブラリであるreact-map-glを使いつつ、手始めに地図を表示してみました。

# 概要
## Amazon Location Serviceとは
Amazon Location Serviceは、Esri、HEREといったプロバイダーが提供する地図をアプリから利用できるサービスです。またGIS（地理情報システム）に必要な機能(ルート検索、ジオコーディング、ジオフェンス等)をAPI経由で利用することができます。

## 主な機能
### ① Maps
Mapsは、地図データを提供し、アプリに地図を表示できる機能です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/560123/f411309a-1a0a-444a-aa71-1103f019f37b.png)
画像の引用元：https://docs.aws.amazon.com/location/latest/developerguide/maps.html

### ② Places
Placesは、座標情報を提供することで、その地点の情報(住所、建物、道路など)をAPI経由で取得できる機能(ジオコーディング)です。また地点の情報から座標情報の逆引き(逆ジオコーディング)もすることができます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/560123/c4485021-a86b-44a5-a6fa-4318e067e004.png)
画像の引用元：https://docs.aws.amazon.com/location/latest/developerguide/places.html

例えば、Googleマップを使っていて、これから行く居酒屋などの位置を検索することは、ないでしょうか？そういった機能を実現できます。

### ③ Routes
Routesは、座標情報を提供することで、移動経路の算出や分析をAPI経由で行うことができる機能です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/560123/36b17aec-e86a-4251-ada1-577a5c2a0626.png)
画像の引用元：https://docs.aws.amazon.com/location/latest/developerguide/routes.html

例えば、旅行の際にGoogleマップを使っていて、空港からホテルまでの経路を調べたりしたことはないでしょうか？そういった機能を実現できます。

### ④ Geofences
Geofencesは、地図上のジオフェンス(境界を表す多角形や円の形状)を管理できます。管理しているジオフェンスを活用して、例えばジオフェンス内に出入りを検知して、イベントを発火して通知することなどができます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/560123/1c612215-60f2-474c-8f7e-9da3fac54863.png)
画像の引用元：https://docs.aws.amazon.com/location/latest/developerguide/geofences.html

### ⑤ Trackers
Trackersは、地図上のデバイスを管理できます。管理しているデバイスの位置を活用して、地図へのデバイスの現在位置の表示や、速度や方位といったメタデータを管理できます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/560123/b2d59dec-197b-4812-a0d0-090591d1671e.png)

Geofences と Trackersを組み合わせることも可能で、Trackersで管理しているデバイスが特定のジオフェンスに出入りしたかをイベント化することができます。

## 2024年11月頃にAmazon Location Serviceがバージョンアップ
以前はMaps、Places、Routesを利用するためにAWS上にリソースを作成する必要がありましたが、バージョンアップ後はリソースを作成せずともAPIキーを発行すれば機能を利用できるようになりました。また、いくつかのAPIが追加され、機能も増えています。

https://ap-northeast-1.console.aws.amazon.com/location/home?region=ap-northeast-1#/feature-spotlight

## react-map-glとは
react-map-glは、元々Mapbox GL JSやMapLibre GL JSというJavaScript向けの地図表示ライブラリがあり、それをReact向けにラップしたライブラリです。Amazon Location Serviceで作成したAPIキーを設定することで、地図表示が可能です。

https://visgl.github.io/react-map-gl/

# 実際に地図を表示してみる
## 1. 準備
### 1.1. フロントエンド環境の準備
１. フロントエンド開発用にViteというツールを使用し、 React と TypeScript のプロジェクトを作成します。
```
npm create vite@latest
```

２. プロジェクトディレクトリに移動し、必要な依存パッケージをインストールします。

```
cd <プロジェクト名>
npm install
```

３. 地図表示に必要な`react-map-gl`と`maplibre-gl`をインストールします。

```
npm add react-map-gl maplibre-gl
```

### 1.2. Amplify環境の準備
１. Amplifyの初期設定を行います。
```
npm create amplify@latest
```

２. サンドボックス環境の立ち上げます。
```
npx ampx sandbox
```

:::note info
**AWS Amplifyのサンドボックス環境とは**
実際のAWSアカウント上に開発用リソースを作成しますが、本番環境とは分離された開発・テスト環境を構築できる機能です。Amplifyのコードを書き替えて保存すると、随時、AWS環境に反映されます。
:::

### 1.3. Amazon Location ServiceのAPIキーを作成
１. APIキーを作成するため、`backend.ts`が作成されているので、下記に変更します。

```ts:./amplify/backend.ts
import { defineBackend } from '@aws-amplify/backend';
import { auth } from './auth/resource';
import { data } from './data/resource';
import { CfnAPIKey } from "aws-cdk-lib/aws-location";


const backend = defineBackend({
  auth,
  data,
});

const locationStack = backend.createStack("location-stack");

new CfnAPIKey(locationStack, "LocationApiKey", {
  keyName: "api-key",
  noExpiry: true,
  restrictions: {
    allowActions: [
      "geo-maps:*"
    ],
    allowResources: [
      `arn:aws:geo-maps:${locationStack.region}::provider/default`
    ]
  }
});
```

:::note info
CfnAPIKeyのパラメータは下記を参照
https://docs.aws.amazon.com/cdk/api/v2/python/aws_cdk.aws_location/CfnAPIKey.html
:::

２. AWSマネジメントコンソールを確認すると、下記のようにAPIキーが作成されているのが分かります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/560123/0b282ef1-17c3-4536-ba35-49acb321ed5d.png)

３. APIキーを選択し、「APIキー値を表示する」ボタンを押します。表示されたAPIキーをメモします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/560123/b8751314-8090-4adf-b9f5-82cceb49e9b1.png)


## 2. 地図を表示してみる
### 2.1. 地図を画面に表示
１. App.tsxを以下のように修正します。

:::note warn
※今回はローカルでお試しなので、先ほどメモしたAPIキーを下記`API_KEY`にべた書きしました。実際にコードを外部へ公開する場合は、セキュリティにご留意ください。
:::

```tsx:./src/App.tsx
import Map from 'react-map-gl/maplibre';
import 'maplibre-gl/dist/maplibre-gl.css';

function App() {

  const API_KEY = "省略"

    // 地図の中心(札幌駅)
  const initialLat: number = 43.06882911246343;
  const initialLon: number = 141.35077817693812;
  const initialZoom: number = 13;

  return (
    <Map
      initialViewState={{
        longitude: initialLat,
        latitude: initialLon,
        zoom: initialZoom
      }}
      style={{width: "100vw", height: "100vh"}}
      mapStyle={`https://maps.geo.ap-northeast-1.amazonaws.com/v2/styles/Standard/descriptor?key=${API_KEY}`}
    />
  )
}

export default App;
```

２． ローカル環境を立ち上げてみます。
```
npm run dev
```

３.　ブラウザで `http://localhost:5173/` にアクセスすると、地図が表示されました！

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/560123/30845f5d-0a70-479c-8532-44f68a1a506e.png)


### 2.2. 地図を日本語化する
１. 地図を日本語化するために以下の関数を新規作成します。
```ts:./src/getStyleWithPreferredLanguage.ts
import type { StyleSpecification, LayerSpecification } from 'react-map-gl/maplibre';
import 'maplibre-gl/dist/maplibre-gl.css';
import { DataDrivenPropertyValueSpecification, FormattedSpecification } from 'maplibre-gl';


const API_KEY = "xxx"

async function getStyleWithPreferredLanguage(preferredLanguage: string) {
  const styleUrl = `https://maps.geo.ap-northeast-1.amazonaws.com/v2/styles/Standard/descriptor?key=${API_KEY}`;

  return fetch(styleUrl)
    .then(response => {
      if (!response.ok) {
        throw new Error('Network response was not ok ' + response.statusText);
      }
      return response.json();
    })
    .then(styleObject => {
      if (preferredLanguage !== "en") {
        styleObject = setPreferredLanguage(styleObject, preferredLanguage);
      }

      return styleObject;
    })
    .catch(error => {
      console.error('Error fetching style:', error);
    });
}

const setPreferredLanguage = (style: StyleSpecification, language: string) => {
  let nextStyle = { ...style };

  nextStyle.layers = nextStyle.layers.map(l => {
    if (l.type !== 'symbol' || !l?.layout?.['text-field']) return l;
    return updateLayer(l, /^name:([A-Za-z\-\_]+)$/g, `name:${language}`);
  });

  return nextStyle;
};

const updateLayer = (layer: LayerSpecification, prevPropertyRegex: RegExp, nextProperty: string) => {
  if (!layer.layout || !('text-field' in layer.layout)) return layer;
  const nextLayer = {
    ...layer,
    layout: {
      ...layer.layout,
      'text-field': recurseExpression(
        layer.layout['text-field'],
        prevPropertyRegex,
        nextProperty
      )
    }
  };
  return nextLayer;
};

const recurseExpression = (
  exp: DataDrivenPropertyValueSpecification<FormattedSpecification> | unknown,
  prevPropertyRegex: RegExp,
  nextProperty: string
): any => {
  if (!Array.isArray(exp)) return exp;
  if (exp[0] !== 'coalesce') return exp.map(v =>
    recurseExpression(v, prevPropertyRegex, nextProperty)
  );

  const first = exp[1];
  const second = exp[2];

  let isMatch =
  Array.isArray(first) &&
  first[0] === 'get' &&
  !!first[1].match(prevPropertyRegex)?.[0];

  isMatch = isMatch && Array.isArray(second) && second[0] === 'get';
  isMatch = isMatch && !exp?.[4];

  if (!isMatch) return exp.map(v =>
    recurseExpression(v, prevPropertyRegex, nextProperty)
  );

  return [
    'coalesce',
    ['get', nextProperty],
    ['get', 'name:en'],
    ['get', 'name']
  ];
};

export default getStyleWithPreferredLanguage;

```

参考
https://docs.aws.amazon.com/location/latest/developerguide/how-to-set-preferred-language-map.html

２. あとはApp.tsxで地図を読み込む際に関数を呼び出します。

```diff_tsx:./src/App.tsx
import { useRef, useCallback } from 'react';
import Map from 'react-map-gl/maplibre';
import type { MapRef } from 'react-map-gl/maplibre';
import 'maplibre-gl/dist/maplibre-gl.css';

+ import getStyleWithPreferredLanguage from './getStyleWithPreferredLanguage';

function App() {
  const mapRef = useRef<MapRef>(null);

- const API_KEY = "省略"
  
  // 地図の中心(札幌駅)
  const initialLon: number = 141.35077817693812;
  const initialLat: number = 43.06882911246343;
  const initialZoom: number = 13;

+ const onMapLoad = useCallback(async () => {
+   if (!mapRef.current) return;
+
+   const styleObject = await getStyleWithPreferredLanguage("ja");
+   const map = mapRef.current.getMap();
+   
+   map.setStyle(styleObject);
+ }, []);

  return (
    <Map
      ref={mapRef}
      initialViewState={{
        longitude: initialLon,
        latitude: initialLat,
        zoom: initialZoom
      }}
      style={{width: "100vw", height: "100vh"}}
+     onLoad={onMapLoad}
-     mapStyle={`https://maps.geo.ap-northeast-1.amazonaws.com/v2/styles/Standard/descriptor?key=${API_KEY}`}
    />
  )
}

export default App;

```

３. 改めて画面を見てみると、地図が日本語化されています！
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/560123/0c2508fd-1b58-48a3-9ed1-9b18a39ad920.png)

### 2.3. 地図にコントロールを追加する
１. 地図にコントロールを追加するため、`App.tsx`を修正します。

```diff_tsx:./src/App.tsx
import { useRef, useCallback } from 'react';
import Map, {
+ NavigationControl,
+ GeolocateControl,
+ FullscreenControl,
+ ScaleControl,
+ AttributionControl,
+ LogoControl
} from 'react-map-gl/maplibre';
import type { MapRef } from 'react-map-gl/maplibre';
import 'maplibre-gl/dist/maplibre-gl.css';

import getStyleWithPreferredLanguage from './getStyleWithPreferredLanguage';

function App() {
  const mapRef = useRef<MapRef>(null);
  
  // 地図の中心(札幌駅)
  const initialLon: number = 141.35077817693812;
  const initialLat: number = 43.06882911246343;
  const initialZoom: number = 13;

  const onMapLoad = useCallback(async () => {
    if (!mapRef.current) return;

    const styleObject = await getStyleWithPreferredLanguage("ja");
    const map = mapRef.current.getMap();

    map.setStyle(styleObject);
  }, []);

  return (
    <Map
      ref={mapRef}
      initialViewState={{
        longitude: initialLon,
        latitude: initialLat,
        zoom: initialZoom
      }}
      style={{width: "100vw", height: "100vh"}}
      onLoad={onMapLoad}
    >
+     <NavigationControl/>
+     <GeolocateControl/>
+     <FullscreenControl/>
+     <ScaleControl/>
+     <AttributionControl/>
+     <LogoControl/>
    </Map>
  )
}

export default App;

```

２. 地図を開くと、地図上にいくつかボタンなどUIが追加されています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/560123/870a2f87-bade-424b-a6a5-3613085bebc8.png)


## 2.4. マーカーとポップアップを追加してみる
１. 地図にマーカーとポップアップを表示するため、`PopupMarker.tsx`を新規作成します。

```tsx:./src/PopupMarker.tsx
import { useState } from 'react';
import { Marker, Popup, MarkerEvent } from 'react-map-gl/maplibre';


function PopupMarker() {

  // マーカー位置(すすきの)
  const markerLat: number = 43.055752329504365;
  const markerLon: number  = 141.35327587910047;

  const [showPopup, setShowPopup] = useState(false);

  const handleMarkerClick = ({ originalEvent }: MarkerEvent<MouseEvent> ) => {
    if (originalEvent) {
      originalEvent.stopPropagation();
      setShowPopup(true);
    };
  };

  return (
    <>
      <Marker
        latitude={markerLat}
        longitude={markerLon}
        onClick={handleMarkerClick}
      />
      {showPopup && (
        <Popup
          latitude={markerLat}
          longitude={markerLon}
          onClose={() => setShowPopup(false)}
        >
          <p>すすきの</p>
        </Popup>
      )}
    </>
  )
}

export default PopupMarker;

```

２. あとは`App.tsx`から呼び出します。

```diff_tsx:./src/App.tsx
import { useRef, useCallback } from 'react';
import Map, {
  NavigationControl,
  GeolocateControl,
  FullscreenControl,
  ScaleControl,
  AttributionControl,
  LogoControl
} from 'react-map-gl/maplibre';
import type { MapRef } from 'react-map-gl/maplibre';
import 'maplibre-gl/dist/maplibre-gl.css';

import getStyleWithPreferredLanguage from './getStyleWithPreferredLanguage';
+ import PopupMarker from './PopupMaker';

function App() {
  const mapRef = useRef<MapRef>(null);
  
  // 地図の中心(札幌駅)
  const initialLon: number = 141.35077817693812;
  const initialLat: number = 43.06882911246343;
  const initialZoom: number = 13;

  const onMapLoad = useCallback(async () => {
    if (!mapRef.current) return;

    const styleObject = await getStyleWithPreferredLanguage("ja");
    const map = mapRef.current.getMap();

    map.setStyle(styleObject);
  }, []);

  return (
    <Map
      ref={mapRef}
      initialViewState={{
        longitude: initialLon,
        latitude: initialLat,
        zoom: initialZoom
      }}
      style={{width: "100vw", height: "100vh"}}
      onLoad={onMapLoad}
    >
      <NavigationControl/>
      <GeolocateControl/>
      <FullscreenControl/>
      <ScaleControl/>
      <AttributionControl/>
      <LogoControl/>
+     <PopupMarker/>
    </Map>
  )
}

export default App;

```

３. 地図にマーカーが表示されました！
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/560123/cf352205-b1ef-4dd2-868c-dabbec82537b.png)

４. マーカーをクリックすると、ポップアップも表示されました！
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/560123/4939bc91-af8d-4587-a1c7-0bd845da0f1e.png)

## 最後に
今回は、簡単な内容ではありますが、Amazon Location Serviceとreact-map-glを使って地図表示してみました。
本来はAmazon Location Serviceをがっつり触りたかったのですが、react-map-gl成分の方が多くなってしまいました...
次回は、場所の検索機能や場所までのルート表示機能あたりを触ってみたいと思います。

# 参考

https://zenn.dev/mierune/articles/try-117-amplify-location