Next.js + deck.gl + HERE Geocoding & Search API で検索機能を実装してみた

この記事では，Next.js + deck.gl + HERE Geocoding & Search APIで，地図上における「場所の検索」機能を実装してみます．

## 何を実装するのか

![demo001.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/93706/03e98ede-025a-85e5-e3b8-e5923c26ede5.gif)

検索欄から「駅」や「コンビニ」といった単語を検索すると，周辺の関連プレイスを表示するWebアプリです．機能自体はシンプルなものです．

今回，検索のAPIは HERE Geocoding & Search API を使用します．

(ちなみにデモ画像は，私の地元である愛知県豊橋市周辺です．)

## 実装環境

* Next.js 12.3.0
* deck.gl 8.8.11
* react-map-gl 7.0.19
* MapLibre GL JS 2.4.0
* HERE Geocoding & Search API v7

今回は，Typescriptで実装します．また，地図表示の基となるベクタースタイルは [MapTiler](https://maptiler.jp/) から取得します．

:::note info
Next.jsやその他諸々の初期設定は割愛します．
ソースコードは基本的に `src` ディレクトリに格納します．
:::

## ディレクトリ構成

ここでは Next.js の `src` ディレクトリ構成を示します．

```
.
├── components
│   ├── Map.tsx
│   └── Search.tsx
├── models
│   └── index.ts
├── pages
│   ├── _app.tsx
│   └── index.tsx
└── styles
    └── globals.css

```

* `components` は，ページ内の部分的なコンポーネントファイル
    * 今回は，地図を表示するコンポーネントと検索フォームのコンポーネントを用意
* `model` は，データの型ファイル
* `pages` は，Webアプリのページを構成するファイル
* `styles`は，スタイル関連のファイル
    * 今回は `globals.css` のみ用意して [tailwind](https://tailwindcss.com/docs/guides/nextjs) の設定を記載

## 実装

まずは，コンポーネント部分を実装していきます．今回は，地図を表示するコンポーネントと，検索フォームのコンポーネントを用意します．

### 地図を表示するコンポーネント

DeckGL + MapLibreGLで，ベースとなる地図表示と，地図上に載せるアイコンなどを表示するコンポーネントです．

```/src/component/Map.tsx
import ReactMapGL from "react-map-gl";
import maplibreGl from "maplibre-gl";
import "maplibre-gl/dist/maplibre-gl.css";
import DeckGL from "@deck.gl/react";
import { IconLayer, TextLayer } from "@deck.gl/layers";
import { NextPage } from "next";
import { useState } from "react";
import { PlaceData } from "~/models";
import Search from "./Search";

const Map: NextPage = () => {
  // 地図上に表示する場所のデータ
  const [places, setPlaces] = useState<[PlaceData]>();

  // 地図の初期位置設定（豊橋駅）
  const [viewState, setViewState] = useState({
    longitude: 137.381651,
    latitude: 34.762811,
    zoom: 12,
  });

  // 場所名を表示するテキストレイヤー
  const textLayer = new TextLayer({
      id: "text-layer",
      data: places,
      background: true,
      backgroundPadding: [12, 4, 12, 4],
      getPixelOffset: [0, -60],
      getColor: [255, 255, 255, 255],
      getBackgroundColor: [64, 64, 64, 255],
      getText: d => d.title,
      getSize: 14,
      fontWeight: 700,
      getTextAnchor: 'middle',
      getAlignmentBaseline: 'bottom',
      getPosition: d => d.coordinates
  })

  // アイコンの情報と表示位置などの設定
  const icon_mapping = {
    url: './img/pin.png',
    x: 0,
    y: 0,
    width: 256,
    height: 256,
    anchorY: 256,
  }

  // アイコンレイヤーの設定
  const iconLayer = new IconLayer({
    id: 'icon-layer',
    data: places,
    sizeScale: 10,
    getIcon: d => icon_mapping,
    getSize: d => 5,
    getPosition: d => d.coordinates,
  })

  // レンダリング
  return (
    <DeckGL
      initialViewState={viewState}
      layers={[iconLayer, textLayer]}
      style={{ width: "100vw", height: "100vh" }}
      controller={true}
      onViewStateChange={(event) => setViewState(event.viewState)}
    >
      <ReactMapGL mapStyle={process.env.MAP_URL} mapLib={maplibreGl} />
      <Search
        centerLon={viewState.longitude}
        centerLat={viewState.latitude}
        setPlaces={setPlaces}
      />
    </DeckGL>
  );
};

export default Map;

```

#### useStateで格納するデータ

`useState`の型は，独自で `PlaceData` 型を定義しています．これは緯度経度の情報と，場所のタイトル（Toyohashi Stationといった名前）を定義しています．この型は `/src/models/index.ts` で定義しています．

```/src/model/index.ts
export type PlaceData = {
  coordinates: [number],
  title: string;
}
```

#### deck.glで使用するレイヤー定義

アイコンや場所を表示するテキストについては deck.gl で実装するため，`TextLayer` と `IconLayer` を定義しています．`IconLayer`で表示するピンの画像は `/public/img/pin.png` に格納されています．

#### レンダリング

`ReactMapGL`にある`mapStyle`は，[MapTiler](https://maptiler.jp/) のスタイルURLを `.env` などの環境変数に格納します．今回は Basic マップを利用しています．

`DeckGL` コンポーネントに内包する形で，検索フォームの `Search` コンポーネントを置いています．propsとして，表示している地図の中心座標( `viewState` の情報)と，表示場所の情報を格納するために必要な `setPlaces` を設定しています．

---

### 検索フォームコンポーネント

入力フォームと検索ボタンを表示するコンポーネントです．ユーザーが「Search」を押すと，HERE Geocoding & Search API を使って場所の検索をします．

```/src/components/Search.tsx
import { NextPage } from "next";
import { Dispatch, SetStateAction } from "react";
import { PlaceData } from "~/models";

type Props = {
  centerLon: number;
  centerLat: number;
  setPlaces: Dispatch<SetStateAction<[PlaceData]>>;
};

const Search: NextPage<Props> = (props: Props) => {
  // 送信ボタンを押したときの処理
  const submit = async (event) => {
    event.preventDefault();

    // データを取得
    const data = {
      value: event.target.search.value,
    };

    // URLのParamを定義
    // at には，いま表示している地図の中心点を格納
    // 今回は最大100件の場所を検索します
    const param = {
      at: `${props.centerLat},${props.centerLon}`,
      q: data.value,
      limit: "100",
      lang: 'en',
      apiKey: process.env.HERE_API_KEY
    };

    // URLのParamをエンコード
    const qs = new URLSearchParams(param);

    try {
      // ジオコーディング検索
      const response = await fetch(
        `https://discover.search.hereapi.com/v1/discover?${qs.toString()}`
      )
        .then((response) => {
          return response.json();
        })
        .then((data) => {
          // タイトル・場所をPlaceDataに合わせる形で抽出
          const placeDatas: [PlaceData] = data.items.map((item) => {
            return {
              title: item.title,
              coordinates: [item.position.lng, item.position.lat],
            };
          });

          // 検索結果を格納
          props.setPlaces(placeDatas);
        });
    } catch (e) {
      console.log(e);
    }
  };

  // レンダリング
  return (
    <form onSubmit={submit}>
      <input
        className="w-80 border-2 border-slate-400 rounded px-2 py-2 ml-8 mr-2 my-2"
        type="text"
        id="search"
        name="search"
        placeholder="Let's Search"
        autoComplete="off"
        required
      />
      <button
        className="bg-blue-700 hover:bg-blue-600 text-white rounded shadow-md px-4 py-2 ml-2 mr-2 my-2"
        type="submit"
      >
        Search
      </button>
    </form>
  );
};

export default Search;

```

#### propsのデータ型

`setPlaces` は，親コンポーネントの更新関数であるため，`Dispatch<SetStateAction<[PlaceData]>>` という型を定義しています．

#### submit関数

:::note info
HEREのAPIを使用するためにはAPI Keyが必要です．予め取得してください．
:::

ユーザーが「Search」ボタンを押したら実行する関数です．

まず，ユーザーが入力した値を取得して，HEREのAPIを呼び出すためのURLのParamsを定義します．今回は，APIの中の `discover request` を使用するため，下記のURLに沿うようにParamsを定義します([公式ドキュメント](https://developer.here.com/documentation/geocoding-search-api/dev_guide/topics/quick-start.html)から参照)．

```
https://discover.search.hereapi.com/v1/
discover
?at=52.5228,13.4124
&q=petrol+station
&apiKey={YOUR_API_KEY}
```

今回は最大100件の場所を表示したいので `limit: "100"` も定義します．また，今回の取得言語は英語に設定しています（日本語を `TextLayer` で表示する場合は `characterSet` の設定が必要です）．

定義したParamsをURLエンコードします．その後　`fetch` を使ってURLを生成してAPIの呼び出しをします．

HEREのAPIのレスポンスデータは下記になります([公式ドキュメント](https://developer.here.com/documentation/geocoding-search-api/dev_guide/topics/quick-start.html)から参照)．

```json

{
  "items": [
    {
      "title": "TOTAL",
      "id": "here:pds:place:276u33dc-2e01d17cb4a24c14bcad179ed8946016",
      "ontologyId": "here:cm:ontology:petrol_gasoline_station",
      "resultType": "place",
      "address": {
        "label": "TOTAL, Prenzlauer Allee 1-4, 10405 Berlin, Deutschland",
        "countryCode": "DEU",
        "countryName": "Deutschland",
        "stateCode": "BE",
        "state": "Berlin",
        "countyCode": "B",
        "county": "Berlin",
        "city": "Berlin",
        "district": "Prenzlauer Berg",
        "street": "Prenzlauer Allee",
        "postalCode": "10405",
        "houseNumber": "1-4"
      },
      "position": { "lat": 52.52896, "lng": 13.41802 },
      "access": [{ "lat": 52.52906, "lng": 13.41775 }],
      "distance": 783,
      "categories": [
        { "id": "700-7600-0116", "name": "Tankstelle", "primary": true },
        { "id": "100-1000-0009", "name": "Fastfood" },
        { "id": "700-7850-0121", "name": "Autowäsche/-reinigung" }
      ],
      "chains": [{ "id": "35" }],
      "references": [
        { "supplier": { "id": "core" }, "id": "50664440" },
        { "supplier": { "id": "yelp" }, "id": "OQn9q-QzNNu8v3eQQQIFNg" },
        { "supplier": { "id": "yelp" }, "id": "j926GEeY9jUex9ESkzJLJg" }
      ],
      "contacts": [
        {
          "phone": [
            { "value": "+49304425643" },
            { "value": "+4949304425643", "categories": [{ "id": "700-7600-0116" }] },
            { "value": "304-425643" }
          ],
          "fax": [
            { "value": "030 4417600", "categories": [{ "id": "700-7600-0116" }] },
            { "value": "304417600", "categories": [{ "id": "700-7600-0116" }] }
          ],
          "www": [
            {
              "value": "http://store.total.de/de/germany/store-total-de/berlin/berlin-prenzlauer-allee-1-4/ND020511",
              "categories": [{ "id": "700-7600-0116" }]
            },
            { "value": "http://store.total.de/de_DE/ND020511" },
            { "value": "http://www.total.de", "categories": [{ "id": "700-7600-0116" }] }
          ]
        }
      ],
      "openingHours": [
        {
          "text": ["Mon-Sun: 00:00 - 24:00"],
          "isOpen": true,
          "structured": [
            { "start": "T000000", "duration": "PT24H00M", "recurrence": "FREQ:DAILY;BYDAY:MO,TU,WE,TH,FR,SA,SU" }
          ]
        }
      ]
    },
    (...)
]
```

このレスポンデータから `PlaceData` 型に合わせる形で `title` と `position` を取り出し， `setPlaces` で親コンポーネントの `places` にデータを格納します．

#### レンダリング

このコンポーネントでは，検索フォームと「Search」ボタンをレンダリングとして定義しています．ユーザーがボタンを押したときは `submit` 関数を呼ぶように設定します．それぞれのスタイルについては，tailwindでいい感じに設定しています．

---

### pages の設定

コンポーネントを実装した後，`pages` でコンポーネントを呼び出します． `index.tsx` では `Map` コンポーネントを定義します．

```/src/pages/_app.tsx
import type { AppProps } from "next/app";
import "~/styles/globals.css";

export default function MyApp({ Component, pageProps }: AppProps) {
  return <Component {...pageProps} />;
}

```

```/src/pages/index.tsx
import { NextPage } from "next";
import Map from "~/components/Map";

const Index: NextPage = () => {
  return (
    <div className="h-screen">
      <Map />
    </div>
  );
};

export default Index;

```

---

## 完成例

Next.jsのサーバーを立ち上げてトップページにアクセスすると，地図画面と検索フォームが表示されます．ユーザーが単語を入力して検索ボタンを押すと，地図の中心点から100件の関連した場所が表示されます．

![demo001.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/93706/03e98ede-025a-85e5-e3b8-e5923c26ede5.gif)

(デモ画像は，最初に貼った画像と同じです)

## 最後に

このように HERE Geocoding & Search API を使うことで，APIを呼び出すだけで手軽に場所の検索ができます．

他にもHEREではルート案内のAPI（HERE Routing API) などが用意されています．ナビゲーションアプリの実装もやってみたいところです．