はじめに

今回は前回に引き続き、Azure Mapsを使った地図表示アプリケーションの実装を行っていきます。前回実装した検索機能をより使いやすくするために、ユーザーの入力内容によって動的に検索結果を地図上に反映できるようなアプリケーションへと修正していきます。

  • 今回の完成イメージ

    今回の完成イメージ

本稿では前回の実装内容をもとに新たに追加する箇所や修正する箇所について説明します。完全なサンプルコードについてはこちらを参照して下さい。

ユーザー入力によるキーワード検索の実装

ユーザーが入力したキーワードによって検索を実行し、取得した検索結果を一覧表示できるようにアプリケーションを変更していきましょう。

検索ボックスの追加

まずはHTML部分を修正し、画面上に検索キーワードを入力できるフォームを追加します。

キーワード検索領域の追加(index.html)

<body onload="initMap()">
<!-- 地図表示領域 -->
    <div id="myMap"></div>

<!-- キーワード検索領域 -->
    <div id="search">
<!-- 検索キーワードの入力フォーム ・・・① -->
        <div class="search-input-box">
            <div class="search-input-group">
                <div class="search-icon" type="button"></div>
                <input id="search-input" type="text" placeholder="Search">
            </div>
        </div>
<!-- 検索結果の表示領域 ・・・② -->
        <ul id="results-panel"></ul>
    </div>
</body>

HTML部分には、「search」というIDを持つ要素を追加しています。この要素には検索キーワードの入力用フォーム(①)と検索結果として取得された位置情報をリストで表示するための領域(②)が含まれています。また今回追加したHTMLに対応するCSSも実装しますが、こちらの説明は割愛するため添付の完成版のHTMLを参照して下さい。

HTMLおよびCSSを実装すると、図のような入力フォームが画面左上に表示されます。

  • 検索キーワードの入力用フォーム

    検索キーワードの入力用フォーム

これ以降では、このHTMLに対して検索処理および検索結果の表示ができるようにJavaScriptを実装していきます。

地図表示の初期化処理の修正

続いて地図表示の初期化処理およびキーワード検索の実行や検索結果の表示処理を行っていた「initMap」メソッドを修正し、検索処理と検索結果の表示処理を分離していきます。

初期化処理の修正(index.html)

<script>
// 変数の初期化・・・①

// 地図インスタンス
let map;
// データソースインスタンス
let datasource;
// ポップアップ用のインスタンス
let popup;

// 検索キーワードを入力する要素のDOM
let searchInput;
// 検索結果を表示する要素のDOM
let resultsPanel;

function initMap() {
    // Azure Maps Web SDKの初期化
    map = new atlas.Map('myMap', { // letを削除 

        // 初期表示の座標・ズーム率を指定 ・・・②
        center: [-122.33, 47.6],
        zoom: 14,
        view: 'Auto',

        ・・・中略
    });

    // 検索キーワード入力領域のDOMを取得する ・・・③
    searchInput = document.getElementById("search-input");
    // 検索キーワード入力領域への文字入力を監視する ・・・④
    searchInput.addEventListener("input", updateSearchInputValue);

    // 検索結果を表示するDOMを取得する ・・・⑤
    resultsPanel = document.getElementById("results-panel");

    // ポップアップ用のインスタンスを作成 ・・・⑥
    popup = new atlas.Popup();

    // 地図レンダリングの完了を待つイベントハンドラーの追加
    map.events.add('ready', function () {
    ・・・中略
    });
});

initMapメソッドから処理を分割して複数のメソッドを定義するため、メソッド間で共通して使用する変数をグローバル変数として定義します(①)。前回の実装までに登場した地図のインスタンス(map)、データソースインスタンス(datasource)、検索結果のポップアップ表示用のインスタンス(popup)をここで宣言するように移動しています。

また、今回追加する検索機能で使用する変数の宣言も追加します。検索用のキーワードを入力するHTML要素のDOMを格納する変数(searchInput)、検索結果を表示するHTML要素のDOMを格納する変数(resultsPanel)の2つを宣言しています。

次にinitMapメソッドの中を変更していきます。Azure Maps Web SDKの初期化処理の中で最初に表示したい場所の座標およびズーム率を指定するようにします(②)。前回のサンプルでは検索実行時に座標を指定していましたが今回は任意のタイミングでの検索となるので、初期表示段階で座標を指定する必要があります。

SDKの初期化処理の後で、各変数への値の代入を行います。検索キーワードの入力用のDOMと検索結果表示用のDOMをそれぞれHTMLから取得して変数へ代入します(③、⑤)。検索キーワードの入力用のDOMの方にはイベントリスナーを追加します(④)。ここでは入力内容の変更を検知する「input」イベントを監視するようにします。このイベントが発生した時に実行するメソッドとして「updateSearchInputValue」を登録します。このメソッドの実装については後ほど説明します。

ポップアップ表示処理の分割

initMapメソッドに含まれていた検索処理と検索結果のピンからポップアップを出現させる処理をそれぞれ別メソッドに切り出していきます。

まずはピンからポップアップを出現させる処理を「showPopup」メソッドとして定義します。

ポップアップの表示用メソッド(index.html)

function showPopup(shape) {
    // データソースからピンの詳細情報を取得する ・・・①
    let prop = shape.getProperties();
    let position = shape.getCoordinates();

    // ポップアップ用のHTMLを構築
    let html = `<div style="padding:5px">
                    <div><b>${prop.poi.name}</b></div>
                    <div>${prop.address.freeformAddress}</div>
                    <div>${position[1]}, ${position[0]}</div>
                </div>`;

    // ポップアップのセットアップ
    popup.setPopupOptions({
        content: html,
        position: position
    });

    // ポップアップを画面に表示する
    popup.open(map);
}

showPopupメソッドは引数として「shape」というオブジェクトを受け取るようにしています。これはポップアップを表示したい地点に存在するピンを表すオブジェクトで、地点の詳細情報や座標情報を取得することができます(①)。shapeから取得した詳細情報を使い、ポップアップの表示処理をメソッド内で行っています。処理内容については前回と同様の実装となっています。 次にこのshowPopupメソッドを呼び出すコードを実装します。

showPopupメソッドの呼び出し(index.html)

// 地図レンダリングの完了を待つイベントハンドラーの追加 ・・・①
map.events.add('ready', function () {
    ・・・中略

    // マウスカーソルがピン上に重なった時にポップアップを表示する ・・・②
    map.events.add('mouseover', searchLayer, function (e) {
        if (e.shapes && e.shapes.length > 0) {
            showPopup(e.shapes[0]); // ポップアップ表示メソッドの呼び出し ・・・③
        }
    });
});

ポップアップの表示は画面上に地図が表示されており、かつ検索処理の実行によって表示されたピン上にマウスが乗ったタイミングで表示する必要があります。そのためinitMapメソッド内にある地図の表示を検知するイベントハンドラー(①)の中にある、マウスカーソルがピン上に重なったことを検知するイベントハンドラー(②)の中でshowPopupメソッドを呼び出します(③)。

検索処理の分割

次に検索処理を分割していきます。検索処理は検索キーワードの取得、検索処理の実行、検索結果の表示の3パートに分けて実装していきます。 まずは検索キーワードの取得を行う「updateSearchInputValue」メソッドを実装します。

検索キーワードの取得(index.html)

function updateSearchInputValue(e) { // inputイベントオブジェクトを引数で受け取る ・・・①
    // 入力時点の値を保持する ・・・②
    let inputValue = e.target.value;

    // 入力内容が3文字以上の場合に検索処理を実行する ・・・③
    if (inputValue.length >= 3) {
        // 検索処理の実行 ・・・④
        setTimeout(async function () {
            // 入力時の値と現在の値(setTimeoutで待機した後の値)が一致したら検索する ・・・⑤
            if (inputValue === e.target.value) {
                await search();
            }
        }, 500); // 500ミリ秒(0.5秒)待機する
    } else {
        resultsPanel.innerHTML = '';
    }
}

updateSearchInputValueメソッドは、前述のinitMapメソッド内に実装した検索キーワードの入力内容を監視するイベントハンドラーが呼び出すメソッドです。そのため、メソッドの引数で入力内容に関するイベントオブジェクトを受け取るようにします(①)。イベントオブジェクトからはユーザーが入力したキーワードが取得できます。後の処理での比較用に、キーワードは一度変数へ代入しておきます(②)。またキーワードの文字数によって検索処理の実行可否を判定するようにします(③)。キーワードの文字数が少ない場合は検索を行わず、無駄な検索処理の呼び出しを削減するようにしています。キーワードの文字数が条件を満たす場合は検索処理用のメソッドを呼び出します(④)。この時setTimeoutメソッドを使って検索処理メソッドの呼び出しを少し遅延させています。ユーザーによってキーワードが追加で入力されている場合を考慮し、ユーザーによる入力が一定時間停止してから検索を実行するようにするためです。ユーザーが入力中かどうかを判定するために、②で変数に代入したキーワードの値と現時点の値を比較しています(⑤)。入力中の場合は値が一致しないため、検索処理は実行されません。

次に検索処理の実行用メソッド「search」を実装します。

検索処理の実行(index.html)

async function search() {
    // 表示中の検索結果をクリアする ・・・①
    datasource.clear();
    popup.close();
    resultsPanel.innerHTML = '';

    // 認証情報を内包したパイプラインインスタンスの作成
    let pipeline = atlas.service.MapsURL.newPipeline(new atlas.service.MapControlCredential(map));

    // Azure MapsのSearch Serviceを操作するためのインスタンスの作成
    let searchURL = new atlas.service.SearchURL(pipeline);

    // 検索キーワードの設定 ・・・②
    let query = searchInput.value;

    // 検索の実行(あいまい検索)
    let results = await searchURL.searchPOI(atlas.service.Aborter.timeout(10000), query, {
        // 現在表示している地図の座標をもとに検索する・・・③
        lon: map.getCamera().center[0],
        lat: map.getCamera().center[1],
        radius: 9000
    });

    // 検索結果をデータソースへ追加
    let data = results.geojson.getFeatures();
    datasource.add(data);

    // 検索結果に応じて地図の表示位置を調整
    map.setCamera({
        bounds: data.bbox
    });

    // 検索結果の表示 ・・・④
    createResultsPanel(data);
}

initMapメソッドから検索処理に関する部分をsearchメソッドに移動し、処理の追加および修正を行います。
まずは複数回検索されることを想定して、メソッドの冒頭で前回の検索結果をクリアします(①)。データソースは内容のクリア、ポップアップは表示されていれば閉じる、検索結果のDOMは空文字で上書くことでそれぞれクリーンアップしています。
次に検索キーワードを入力領域から取得します(②)。前回の例ではここが固定の値でしたが、今回は画面上から自由にキーワードを変更することができるようになります。次に②で取得したキーワードを使って検索を実行します。検索対象となる座標の指定には、現在表示している地図の中心にあたる箇所の座標を使用します(③)。この座標も前回は固定値となっていましたが、任意の地点で検索できるように変更しています。
メソッドの最後で、後述する検索結果表示用のメソッドを呼び出します(④)。引数には検索結果のオブジェクトを渡します。

検索結果の表示は「createResultsPanel」メソッドに実装していきます。

検索結果の表示(index.html)

function createResultsPanel(data) {
    let html = [];

    // HTMLの構築 ・・・①
    for (let i = 0; i < data.features.length; i++) {
        let r = data.features[i];
        // データ行に対するマウス操作のハンドラーを追加 ・・・②
        html.push(`<li onclick="itemClicked('${r.id}')" onmouseover="itemHovered('${r.id}')">`);

        // 地点の名称(または住所)
        html.push(`<div class="title">`);
        if (r.properties.poi && r.properties.poi.name) {
            html.push(r.properties.poi.name);
        } else {
            html.push(r.properties.address.freeformAddress);
        }
        html.push(`</div>`);

        // 住所
        html.push(`<div class="info">${r.properties.type}: ${r.properties.address.freeformAddress}</div>`);


        // 電話番号・ホームページ
        if (r.properties.poi) {
            if (r.properties.phone) {
                html.push(`<div class="info">phone: ${r.properties.poi.phone}</div>`);
            }
            if (r.properties.poi.url) {
                html.push(`<div class="info"><a href="http://${r.properties.poi.url}">http://${r.properties.poi.url}</a></div>`);
            }
        }
        html.push(`</li>`);

        // 検索結果用のDOMに構築したHTMLを代入 ・・・③
        resultsPanel.innerHTML = html.join('');
    }
}

createResultsPanelメソッドではsearchメソッドで取得した検索結果オブジェクトをもとに検索結果の一覧をHTMLに描画する処理を実装します(①)。検索結果オブジェクトには、検索キーワードにヒットした各地点の名称・住所・電話番号・ホームページのURLなどが含まれているのでこれらを表示できるようにHTMLを組み立てていき、1件ずつ結果表示用のDOMに追加していきます(③)。また検索結果一覧上で任意の検索結果にマウスカーソルを乗せた場合やクリックした時の処理も実装します(②)。

検索結果の表示にマウスカーソルを乗せた場合の処理を「itemHovered」メソッド、クリックした場合の処理を「itemClicked」メソッドとして実装します。

検索結果に対するマウス操作の処理(index.html)

// 検索結果の表示にマウスカーソルを乗せた場合の処理 ・・・①
function itemHovered(id) {
    var shape = datasource.getShapeById(id);
    // ポップアップを表示する
    showPopup(shape);
}

// 検索結果の表示をクリックした場合の処理 ・・・②
function itemClicked(id) {
    var shape = datasource.getShapeById(id);
    // クリックした地点が中央となるようにカメラを移動する
    map.setCamera({
        center: shape.getCoordinates(),
        zoom: 17
    });
}

itemHoveredメソッドでは、受け取ったIDをもとに地点のピン情報を取得してピン上にポップアップを表示する処理を実装しています。ポップアップの表示には前述したshowPopupメソッドを再利用しています(①)。 itemClickedメソッドも同様に受け取ったIDからピン情報を取得し、ピンの座標が中心となるようにカメラを移動する処理を実装しています(②)。

ここまで実装が完了したらファイルを保存し、ブラウザで動作確認をしてみましょう。

  • キーワード検索の例

    キーワード検索の例

検索の実行および検索結果の表示、ポップアップの表示やカメラの移動などが動作していることが確認できれば完成です。

まとめ

全3回にわたってAzure Mapsについて紹介し、Azure Mapsのサービス群のひとつであるSearch Serviceを使ったキーワード検索の実装方法について詳しく説明をしました。Azure Mapsをアプリケーションに組み込むことで、複雑かつ広範な地理情報関連の機能を比較的容易に扱えることが分かったかと思います。執筆時点では日本語および日本地域におけるサポートが未対応であるサービス群が多い状況ですが、地理情報を扱うサービスを構築する上でAzure Mapsの導入も検討してみてはいかがでしょうか。

WINGSプロジェクト 秋葉龍一著/山田祥寛監修
<WINGSプロジェクトについて>テクニカル執筆プロジェクト(代表山田祥寛)。海外記事の翻訳から、主にWeb開発分野の書籍・雑誌/Web記事の執筆、講演等を幅広く手がける。一緒に執筆をできる有志を募集中