9
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

この記事では,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) などが用意されています.ナビゲーションアプリの実装もやってみたいところです.

9
1
0

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
  3. You can use dark theme
What you can do with signing up
9
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?