search
LoginSignup
0

posted at

updated at

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

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

何を実装するのか

demo001.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 から取得します.

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 の設定を記載

実装

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

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

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 で実装するため,TextLayerIconLayer を定義しています.IconLayerで表示するピンの画像は /public/img/pin.png に格納されています.

レンダリング

ReactMapGLにあるmapStyleは,MapTiler のスタイル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関数

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

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

まず,ユーザーが入力した値を取得して,HEREのAPIを呼び出すためのURLのParamsを定義します.今回は,APIの中の discover request を使用するため,下記のURLに沿うようにParamsを定義します(公式ドキュメントから参照).

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のレスポンスデータは下記になります(公式ドキュメントから参照).


{
  "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 型に合わせる形で titleposition を取り出し, 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

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

最後に

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

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

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
What you can do with signing up
0