はじめての ArcGIS API for JavaScript

今回は ArcGIS API for JavaScript を利用して、以下のような地図アプリを作る方法を解説します。

![ArcGIS API for JavaScript のイメージ](https://github.com/yoshikawaa/images/raw/master/arcgis-js/image-of-arcgis-js.jpg)

## 使用するライブラリ・データ

* [ArcGIS API for JavaScript:4.22](https://developers.arcgis.com/javascript/latest/)
* [オープンストリートマップ](https://www.arcgis.com/home/item.html?id=de26a3cf4cc9451298ea173c4b324736)
* [都道府県のGeoJSON](https://hub.arcgis.com/datasets/solutions-esrij::japan-opendata-pref)
* [市区町村のGeoJSON](https://hub.arcgis.com/datasets/solutions-esrij::japan-opendata-city)

## アーキテクチャ

地図アプリ（GIS）は、地図情報（絵）に地理情報（データ）を加えて、操作や分析を可能にするものです。前提知識についてはArcGISの開発元である[Esri社のページ](https://www.esrij.com/getting-started/gis-guide/)が分かりやすくてオススメです。

ArcGIS API for JavaScript のアーキテクチャを簡単な図で表すと、以下のようになります。

![ArcGIS API for JavaScript のアーキテクチャ](https://github.com/yoshikawaa/images/raw/master/arcgis-js/architecture-of-arcgis-js.jpg)

| Name | Description |
|---|---|
| Container | 地図を表示するHTMLの領域です。ViewをContainer要素に紐づけます。 |
| View | 地図操作のベースコンポーネントです。表示座標やズーム倍率などを管理します。 |
| Map | 地図情報のコンポーネントです。表示する地図と、地図に重ねる地理情報のレイヤを管理します。 |
| Layer | 地理情報のコンポーネントです。地理情報にはベクタとラスタがありますが、ベクタでは座標から点・線・面のオブジェクトが表現され、それが何であるかの情報を持ちます。 |
| UI | 地図に追加されるUIコンポーネントです。例えば、GPS情報から現在地に移動したり、地理情報を検索するUIを追加することができます。 |

ArcGISでは Map で利用する地図情報をマップサービス、 Layer で利用する地理情報をジオデータサービスとして公開することができます。 Layer はジオデータサービスの地理情報だけでなく、標準的なGeoJSON、KML、OGCなどのさまざまなフォーマットを扱うことが可能です。

## 地図アプリの実装

今回は、ArcGIS Onlineで無料公開されているデータだけを利用して、地図アプリを実装します。

### ライブラリの読み込み

ArcGIS API for JavaScript を読み込みます。

```html
<head>
    <link rel="stylesheet" href="https://js.arcgis.com/4.22/esri/themes/light/main.css">
    <script src="https://js.arcgis.com/4.22/"></script>
</head>
```

### モジュールの追加

ArcGIS API for JavaScript はモジュール形式となっており、使用したいコンポーネントごとにモジュールを読み込みます。リファレンスに `autocast` とある[一部のコンポーネント](https://developers.arcgis.com/javascript/latest/api-reference/esri-Map.html#basemap)は、自動的に読み込まれます。

次の例では、最低限の View、Map、Layer モジュールを読み込みます。

```html
<head>
    ...
    <script>
        require([
            "esri/config",
            "esri/Map",
            "esri/views/MapView",
            "esri/layers/GeoJSONLayer",
        ], (esriConfig, Map, MapView, GeoJSONLayer) => {

            // 実装する

        });
    </script>
</head>
```

`require` か `import` でモジュールを読み込み、アロー関数（または `function` ）の引数に同じ順序で追加します。

### Layerの生成

> ArcGIS標準のジオデータサービスを利用する場合は GeoJSONLayer ではなく FeatureLayer を利用します。実装方法は変わらないので、今回はArcGISユーザ以外にも分かりやすいGeoJSONをベースに解説します。

* 利用するコンポーネント：[GeoJSONLayer](https://developers.arcgis.com/javascript/latest/api-reference/esri-layers-GeoJSONLayer.html)
* 利用するデータ：[都道府県のGeoJSON](https://hub.arcgis.com/datasets/solutions-esrij::japan-opendata-pref)

ArcGIS Hubで公開されるGeoJSON形式の地理情報から Layer を生成します。
ファイル形式に対応したコンポーネントを使用することで、GeoJSON以外から Layer を生成することもできます。

```javascript
const prefLayer = new GeoJSONLayer({
    url: "https://opendata.arcgis.com/datasets/dd492422cd2e4eb09eb7bb7aa2ca91b6_0.geojson",
    id: "pref",
    outFields: ["japan_pref_pref", ...]
});
```

最小セットとして、GeoJSONを取得する `url` と、レイヤを識別する `id` を指定します。`id` は自動付与されますが、解析しにくいので指定したほうが良いでしょう。

`outFields` で、GeoJSONのプロパティをオブジェクトの属性値として持たせることができます。これにより、プログラムから地図上のオブジェクトが何であるかを読み取ることができるようになります。

Layer については、[高度な使い方](#高度な使い方)でさらに解説していきます。

### Mapの生成

* 利用するコンポーネント：[Map](https://developers.arcgis.com/javascript/latest/api-reference/esri-Map.html)
* 利用する地図：[オープンストリートマップ](https://www.arcgis.com/home/item.html?id=de26a3cf4cc9451298ea173c4b324736)

ArcGIS Onlineで公開されるベースマップから Map を生成します。
Map は ベースマップに地理情報の Layer を重ねることができます。

```javascript
const map = new Map({
    basemap: "streets-vector",
    layers: [prefLayer, ...]
});
```

ここでは、ベースマップとして無料公開されているオープンストリートマップを読み込みます。

### Viewの生成とContainerの配置

* 利用するコンポーネント：[MapView](https://developers.arcgis.com/javascript/latest/api-reference/esri-views-MapView.html)

地図を表示するための View を生成します。

```javascript
const view = new MapView({
    map: map,
    container: "viewDiv",
    center: [138.727363, 35.360626],
    zoom: 6,
});
```

`map` に生成した Map オブジェクトを指定し、`container` に表示するHTMLの領域を指定します。
`center` と `zoom` で初期表示する座標と拡大率を指定することで、ユーザをエントリポイントに誘導できます。

```html
<head>
    ...
    <style>
        html,
        body,
        #viewDiv {
            padding: 0;
            margin: 0;
            height: 100%;
            width: 100%;
        }
    </style>    
</head>
<body>
    <div id="viewDiv"></div>
</body>
```

最後に、HTMLボディに `container` で指定した要素を配置すれば、地図アプリの完成です。

## UIの実装

View、Map、Layer だけの地図アプリでは寂しいので、UIを追加していきます。


### スケールの追加

* 利用するコンポーネント：[ScaleBar](https://developers.arcgis.com/javascript/latest/api-reference/esri-widgets-ScaleBar.html)

View にスケール（縮尺）を追加します。
ズームを多用する場合に役立ちます。

```javascript
// require(["esri/widgets/ScaleBar"], (ScaleBar) => { ...
const scalebar = new ScaleBar({
    view: view,
    unit: "dual"
});
view.ui.add(scalebar, "bottom-left");
```

`bottom-left` は UI を追加する地図上の位置を指します。

### コンパスの追加

* 利用するコンポーネント：[Compass](https://developers.arcgis.com/javascript/latest/api-reference/esri-widgets-Compass.html)

View にコンパス（方位磁針）を追加します。
タブレットやモバイルで地図を回転させる場合に役立ちます。

```javascript
// require(["esri/widgets/Compass"], (Compass) => { ...
const compass = new Compass({
    view: view,
});
view.ui.add(compass, "bottom-left");
```

### 現在地ボタンの追加

* 利用するコンポーネント：[Locate](https://developers.arcgis.com/javascript/latest/api-reference/esri-widgets-Locate.html)

View に現在地ボタンを追加します。
現在地の周辺にある地理データを探す場合に役立ちます。

```javascript
// require(["esri/widgets/Locate"], (Locate) => { ...
const locateBtn = new Locate({
    view: view
});
view.ui.add(locateBtn, "top-left");
```

現在地ボタンはセキュリティ保護されていないオリジンではサポートされず、画面に表示されません。（地図アプリをHTTPSで公開する必要があります。）

### 検索ボックスの追加

* 利用するコンポーネント：[Search](https://developers.arcgis.com/javascript/latest/api-reference/esri-widgets-Search.html)

View に検索ボックスを追加します。
検索ボックスを使うと、 地理データを検索して瞬時に移動することができます。

```javascript
// require(["esri/widgets/Search"], (Search) => { ...
const search = new Search({
    view: view,
    autoSelect: true,
    minSuggestCharacters: 1,
    includeDefaultSources: false,
    sources: [
        { layer: prefLayer, name: "都道府県", zoomScale: 1000000 },
        { layer: cityLayer, name: "都市", zoomScale: 500000 }
    ]
});
view.ui.add(search, "top-right");
```

デフォルトでは ArcGIS World Geocoding Service を利用して任意の地理データを検索します。
地図アプリで扱う Layer の地理データを検索したい場合は `sources` に Layer を指定し、必要に応じて `includeDefaultSources` を指定して ArcGIS World Geocoding Service をOFFります。

検索ボックスに表示される Layer 名は `name` で指定します。
`zoomScale` の指定により、検索結果を選択したときのズーム倍率を制御できます。

> 拡大率の指定にはレベルとスケールの２つの方法があります。
> レベルは拡大率を示し、数字が大きいほど拡大率が高いです。
> スケールは縮尺を示し、その縮尺まで拡大するため数字が小さいほど拡大率が高いです。
> [Zoom levels and scale](https://developers.arcgis.com/documentation/mapping-apis-and-services/reference/zoom-levels-and-scale/)

検索ボックスでは入力から結果をサジェストしてくれますが、 `minSuggestCharacters` の指定によりサジェストが発生する文字数を制御できます。デフォルトは３文字ですが、都道府県はほとんど３文字なのでこれではサジェストがうまく機能しません。

> Layer にデータフィールドを複数持つ場合、 `searchFields` で検索に利用するフィールドを指定することができます。また、 `searchFields` でサジェストに表示されるフィールドを指定することができます。

### ベースマップを切り替える

* 利用するコンポーネント：[BasemapGallery](https://developers.arcgis.com/javascript/latest/api-reference/esri-widgets-BasemapGallery.html)

Google Mapのようにベースマップを切り替えることもできます。

```javascript
// require(["esri/widgets/BasemapGallery"], (BasemapGallery) => { ...
const basemapGallery = new BasemapGallery({
    view: view
});
view.ui.add(basemapGallery, "bottom-right");
```

## 動作の制御

地図アプリにロジックを追加して、動作を制御します。

### イベントハンドラの実装

View や一部の UI コンポーネントは、イベントをパブリッシュします。
`on` メソッドでイベントハンドラを実装することで、イベントを補足してロジックを実行できます。

```javascript
view.on("click", (event) => {
    view.hitTest(event).then((response) => {
        const graphic = response.results[0]?.graphic;
        if (graphic?.layer === prefLayer) {
            view.goTo({
                center: graphic.geometry.centroid,
                zoom: 10
            });
        }
    });
});
```

ここでは、地図上でいずれかの都道府県をクリックしたときに、指定倍率までズームしています。

`hitTest` メソッドにより、パブリッシュされたイベントからクリックされたオブジェクトを取得しています。`hitTest` メソッドでは、もう少し複雑な指定によりイベントから特定の Layer の結果だけ受け取ったりもできますが、今のところあまりメリットを感じていません。

### ウォッチャーの実装

（きっと）すべてのコンポーネントの祖先である `Accessor` には `watch` メソッドがあり、
プロパティの変更を監視することができます。

```javascript
view.watch("zoom", (newValue, oldValue) => {
    console.log("zoom level: " + oldValue + " to " + newValue);
});
```

ここでは、ズームレベルの変更を監視してログ出力しています。（意味のない処理ですが）
なお、孫プロパティを監視するときは `abc.def` のように指定します。

`watch` により従来のイベント駆動プログラミングではなく、リアクティブプログラミングに移行することが可能となります。

### View 生成を保証する

一部の処理は非同期で実行されるため、[プロミス](https://developer.mozilla.org/ja/docs/Web/JavaScript/Guide/Using_promises)を利用して完了を保証する必要があります。

以下では、 View の生成が完了した後にロジックが実行されます。

```javascript
view.when(() => {

    // ロジックを実装する

}, (error) => {

    // エラーハンドリングを実装する

});
```

以下では、 特定の Layer を表示する LayerView の生成が完了した後にロジックが実行されます。

```javascript
view.whenLayerView(prefLayer)
    .then((layerView) => {

        // ロジックを実装する

    .catch((error) => {

        // エラーハンドリングを実装する

    });
```

初期化前にロジックが実行され不正な状態となってしまうことを防止できるので、イベントハンドラやウォッチャーは基本的に `when` 配下に実装すると良さそうです。

## 高度な使い方

ここからは、少し高度な使い方を解説します。

### Layer をカスタマイズする

#### オブジェクトの表示を制御する

##### オブジェクトの描画

`renderer` の指定により、オブジェクトの描画を変更することができます。

```javascript
const prefLayer = new GeoJSONLayer({
    url: "https://opendata.arcgis.com/datasets/dd492422cd2e4eb09eb7bb7aa2ca91b6_0.geojson",
    id: "pref",
    outFields: ["japan_pref_pref", ...]
    renderer: {
        type: "simple",
        symbol: {
            type: "simple-fill",
            color: [128, 128, 128, 0.5],
            outline: {
                color: "white"
            }
        }
    }
});
```

ここでは、オブジェクトの表示色を変更してグレーの透過色にしています。
例えば、面の地理データを点として表示することも可能です。

##### ラベルの表示

`labelingInfo` の指定により、オブジェクトにラベルを表示することができます。

```javascript
const prefLayer = new GeoJSONLayer({
    url: "https://opendata.arcgis.com/datasets/dd492422cd2e4eb09eb7bb7aa2ca91b6_0.geojson",
    id: "pref",
    outFields: ["japan_pref_pref", ...]
    labelingInfo: [{
        labelExpressionInfo: { expression: "$feature.japan_pref_pref" },
        labelPlacement: "above-center",
        symbol: {
            type: "text",
            color: "black",
            haloSize: 1,
            haloColor: "white"
        }
    }]
});
```

`labelExpressionInfo` の `expression` でラベルの内容を指定します。
`labelPlacement` でラベルの表示位置を変更することもできます。

##### ポインタイベントでオブジェクトをハイライトする

`highlightOptions` の指定により、選択したオブジェクトをハイライトすることができます。

```javascript
const view = new MapView({
    map: map,
    container: "viewDiv",
    zoom: 6,
    center: [138.727363, 35.360626],
    highlightOptions: {
        color: "red"
    }
});
```

ハイライトするには、LayerViewの `highlight` メソッドを呼び出す必要があります。
以下では、ポインタがオブジェクトに触れている間はハイライトしています。

```javascript
let highlighted = null;
view.on("pointer-move", (event) => {
    view.hitTest(event).then((response) => {
        const graphic = response.results[0]?.graphic;
        const objectId = graphic.attributes.__OBJECTID;
        const layer = graphic?.layer;
        if (map.layers.includes(layer)) {
            // 何度もハイライトされるため、同一オブジェクトでは抑止
            if (highlighted?.objectId !== objectId) {
                highlighted?.highlight.remove();
                view.whenLayerView(layer)
                    .then((layerView) => highlighted = {
                        objectId: objectId,
                        highlight: layerView.highlight(graphic)
                    });
            }
        } else {
            highlighted?.highlight.remove();
        }
    });
});
```

`graphic` （地理データオブジェクト）はユニークIDを持つため、これをキーに同一オブジェクトかどうかを判断しすることができます。なお、ユニークIDは Layer ごとに振られるため、複数 Layer をアクティブにしている場合は「Layer ID + Object ID」で判断する必要がありそうです。

後でハイライトを消すために、 `highlight` メソッドの戻り値を保持しておく必要があります。
なお、ポップアップを表示する場合は自動的にハイライトが制御されるようです。

#### ポップアップを制御する

`popupTemplate` の指定により、オブジェクトに表示されるポップアップの内容を変更することができます。

```javascript
const prefLayer = new GeoJSONLayer({
    url: "https://opendata.arcgis.com/datasets/dd492422cd2e4eb09eb7bb7aa2ca91b6_0.geojson",
    id: "pref",
    outFields: ["japan_pref_pref", ...]
    popupTemplate: {
        title: "{japan_pref_pref}",
        content: "<a href='{opendata_pref_url1}'>ホームページ</a>",
        overwriteActions: true,
        actions: [
            {
                title: "ズームアウト",
                id: "zoom-out",
                className: "esri-icon-zoom-out-magnifying-glass"
            },
        ]
    },
    popupEnabled: false
});
```

`popupTemplate` の `title` には、GeoJSONのプロパティからオブジェクトの表示名を指定します。
`content` には、HTMLやデータを指定してコンテンツを生成することができます。詳細は[Content](https://developers.arcgis.com/javascript/latest/api-reference/esri-popup-content-Content.html)を参照。

ポップアップにはデフォルトで「ズーム」というボタンが表示されますが、`actions` の指定により任意のボタンを追加することができます。アクションの `id` はイベント名となり、以下のように [Popup](https://developers.arcgis.com/javascript/latest/api-reference/esri-widgets-Popup.html)の `trigger-action` イベントハンドラで補足することができます。

> デフォルトの「ズーム」ボタンからは `zoom-to-feature` イベントがパブリッシュされますが、これの挙動が気に入らない場合は `overwriteActions` で「ズーム」ボタンを消去することができます。

最後に、デフォルトではオブジェクトをクリックするとポップアップが表示されますが、 `popupEnabled` の指定により無効化することができます。 `click` イベントの実装に合わせて使うと良さそうです。

##### ポインタイベントでポップアップを制御する

ポインタがオブジェクトを指しているときに自動的にポップアップを表示する場合、少々厄介です。
ポインタの動作は `pointer-move` イベントハンドラで捕捉するのですが、これはポインタが動くと常に動作するため、オブジェクト内でポインタを移動させたり、ポップアップのボタンをクリックしようとすると、何度もポップアップが再描画されてしまいます。

実に原始的ですが、以下ではポインタが同一オブジェクト内にいる場合はポップアップの再描画を抑止しています。

```javascript
let popupedObjectId = null;
view.on("pointer-move", (event) => {
    view.hitTest(event).then((response) => {
        const graphic = response.results[0]?.graphic;
        if (map.layers.includes(graphic?.layer)) {
            // ポップアップが何度も再描画されるため、同一オブジェクトでは抑止
            const objectId = graphic.attributes.__OBJECTID;
            if (popupedObjectId != objectId) {
                popupedObjectId = objectId;
                view.popup.open({
                    location: graphic.geometry.centroid,
                    features: [graphic]
                });
            }
        // ポインタイベントで表示したポップアップのみクローズ
        } else if (popupedObjectId === view.popup.features[0]?.attributes.__OBJECTID) {
            popupedObjectId = null;
            view.popup.close();
        }
    });
});
```

`graphic` （地理データオブジェクト）はユニークIDを持つため、これをキーに同一オブジェクトかどうかを判断しすることができます。なお、ユニークIDは Layer ごとに振られるため、複数 Layer をアクティブにしている場合は「Layer ID + Object ID」で判断する必要がありそうです。

> Layer 以外に「ベースマップの機能」や「現在地ボタンで表示された現在地の座標」などでもポップアップが表示される可能性があります。このため、ポップアップをクローズする際には「閉じて良いポップアップか」を確認することをお勧めします。

### 複数の Layer を使用する

#### 表示する Layer を切り替える

都道府県と市区町村のように包含関係にあたる地理データを扱う場合、ズームの拡大率によって表示をする地理情報を切り替えたくなると思います。

ここでは Layer を切り替える方法を２つ紹介します。（他にもあると思います。）

##### 方法１：`minScale`, `maxScale` を利用する

Layer にはスケールによって表示／非表示を切り替える機能があります。

```javascript
const prefLayer = new GeoJSONLayer({
    url: "https://opendata.arcgis.com/datasets/dd492422cd2e4eb09eb7bb7aa2ca91b6_0.geojson",
    id: "pref",
    outFields: ["japan_pref_pref", ...]
    maxScale: 750000
});
const cityLayer = new GeoJSONLayer({
    url: "https://opendata.arcgis.com/datasets/ed99f2e4eab9416993020c97a429459f_0.geojson",
    id: "city",
    outFields: ["USER_city_name", ...]
    minScale: 750000
});
```

`minScale` と `maxScale` により、ある倍率からは表示される Layer が切り替わります。

##### 方法２：`visible` を利用する

`visible` を指定することで、プログラマティックに表示／非表示を切り替えることができます。

```javascript
const prefLayer = new GeoJSONLayer({
    url: "https://opendata.arcgis.com/datasets/dd492422cd2e4eb09eb7bb7aa2ca91b6_0.geojson",
    id: "pref",
    outFields: ["japan_pref_pref", ...]
});
const cityLayer = new GeoJSONLayer({
    url: "https://opendata.arcgis.com/datasets/ed99f2e4eab9416993020c97a429459f_0.geojson",
    id: "city",
    outFields: ["USER_city_name", ...]
    visible: false
});
```

方法１と同様に拡大率で切り替えるなら、ウォッチャーに切り替えロジックを実装すれば良いです。

```javascript
view.watch("zoom", (newValue, oldValue) => {
    // zoomの値により、cityLayer.visible = true;
});
```

#### すべての Layer の生成を保証する

複数の Layer にまたがるイベントを実装する場合は、念のためすべての Layer の生成を保証しましょう。

```javascript
Promise.all([
    view.whenLayerView(prefLayer),
    view.whenLayerView(cityLayer)
]).then([prefLayerView, cityLayerView]) => {

    // ロジックを実装する

});
```

`Promise.all` により、複数の非同期処理の完了を保証することができます。

### Layer のオブジェクトを検索する

#### プログラムで検索する

Layer のオブジェクトに対してクエリを発行することができます。
これを利用して、 プログラマティックに特定のオブジェクトに移動するような操作を実現できます。

以下では、都道府県から北海道を検索して移動します。

```javascript
const queryParams = prefLayer.createQuery();
queryParams.where = "japan_pref_pref = '北海道'"
prefLayer.queryFeatures(queryParams)
    .then((results) => {
        if (results.features.length > 0) {
            view.goTo(results.features[0]);
        }
    });
```

`createQuery` メソッドで Layer に対するクエリを生成し、 `where` で検索条件を指定します。
`queryFeatures` メソッドでクエリを発行し、検索結果（ `features` ）に対する処理を実装します。

> クエリのWHERE句では、GeoJSONのプロパティを使用できます。（ `outFields` に指定していないプロパティも使用できます。）

#### Layer の表示を制限する

Layer にクエリを直接適用することで、 Layerに表示するオブジェクトを制限することができます。
Layer のデータセットが非常に大きい場合や、ユーザに見せる必要のないオブジェクトを除外するときに利用します。

以下では、既に表示されている都市を北海道の都市のみに制限します。

```javascript
cityLayer.definitionExpression = "USER_city_name LIKE '北海道%'";
```

`definitionExpression` に検索条件を指定します。
最初から制限する場合は、Layer 定義時に指定します。上記のように後から指定した場合は、 表示を制限するよう View が自動的に更新されます。後者は特に、「選択した都道府県の都市を表示する」ように複数の Layer の状態を連動させる場合に効果を発揮します。

## まとめ

はじめての ArcGIS でしたが、思ったより簡単に地図アプリを実装することができました。
オープンデータではなくプライベートなマップサービスを作るところまでいくと、難しさを感じてくるのだろうと思います。

公式リファレンスが充実しているので英語が苦手でなければ実装に困ることはないと思いますが、日本語で参考になる記事が少なかったので書いてみました。

参考
* [ArcGIS Developer 開発リソース集 | ArcGIS API for JavaScript](https://esrijapan.github.io/arcgis-dev-resources/guide/create-app/create-startup-app-js/)
* [ArcGIS API for JavaScript | API Reference](https://developers.arcgis.com/javascript/latest/api-reference/)