Esri Feature Service のサーバーからnodejsを使ってJSONデータを取り出してみる。

# はじめに
私の同僚はデータをEsri Feature Serverから出しています。今回、このデータを直接ベクトルタイルに変換できないかという相談があったので、実験しました。item としてgeojsonでも公開してくれていれば楽なのですが、現在はfeature serviceしかないので、ここからGeoJSONに近づけることを検討します。

公開されているデータなので、URLを書いてしまうと、レイヤは以下の２つを対象にします。
* https://geoservices.un.org/arcgis/rest/services/Hosted/UN_Geodata_Stylized/FeatureServer/0?f=pjson （ライン）
* https://geoservices.un.org/arcgis/rest/services/Hosted/UN_Geodata_Stylized/FeatureServer/3?f=pjson （ポリゴン）

この記事は、将来的に ArcGIS Feature Serviceに出ているデータを直接ベクトルタイル化するための第一歩です。


# 取り組み
## Esri Feature Service のサーバー、APIを理解しようと努力する。
まず、[ESRI Feature service API](https://developers.arcgis.com/rest/services-reference/enterprise/feature-service.htm)を読んで仕組みをある程度理解しようとしました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/679143/26334df2-51e5-69fc-2569-b204935377f4.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/679143/5ed45c7f-7024-8708-5c50-2e978ccf6dcd.png)

とりあえず発見したこと：
* FeatureサーバーのURLに加えて、レイヤ番号を入れれば、レイヤの情報にアクセスできます。（f=pjson 等のファイル形式指定が必要でした）
  * 例：https://geoservices.un.org/arcgis/rest/services/Hosted/UN_Geodata_Stylized/FeatureServer/0?f=pjson 
* FeatureサーバーのURLに加えて、レイヤ番号と地物番号（featureId）を入れれば個別の地物レコードのJSONファイルにアクセスできます。
  * 例：https://geoservices.un.org/arcgis/rest/services/Hosted/UN_Geodata_Stylized/FeatureServer/0/1?f=pjson
  * 面白いことに、地物番号では 0 がなくて1 から始まるようです。また、このＵＲＬに入れるfeatureIdは、実際のレコードに書いてある属性のどれとも紐づいていないようなので、システムが使っている番号のようです。（ですので、地物の数より大きい番号のfeatureIdは存在しない様子。→あとで大事になってきます。）


例えば、レイヤ情報にアクセスするには以下の通りです。
https://geoservices.un.org/arcgis/rest/services/Hosted/UN_Geodata_Stylized/FeatureServer/0?f=pjson
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/679143/86a5932b-3dbb-d3e7-2858-94c131c7a38f.png)

私が試したサーバーではクエリがうまく使えないのか、私のやり方が悪いのかわからないのですが、うまくレイヤ中の地物をすべてダウンロードしてくる方法が見つかりませんでした。


## Feature Serviceで返すJSONを観察する
Layerのもう一つ下のレベルまで指定すると地物の情報を得られます。地物IDを指定して帰ってくる１地物のJSONファイルがGeoJSON（GeoJSONsequenceでよい）と整合的なのか確認します。結論をいうと少し構造が違うので、使う際には注意が必要です。

これが Esri Feature Service による ラインデータの１レコード分のJSONです。
https://geoservices.un.org/arcgis/rest/services/Hosted/UN_Geodata_Stylized/FeatureServer/0/1?f=pjson
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/679143/24ecf10b-53cc-5295-1d84-25537a671718.png)

これが Esri Feature Service による ポリゴンデータの１レコード分のJSONです。
https://geoservices.un.org/arcgis/rest/services/Hosted/UN_Geodata_Stylized/FeatureServer/3/246?f=pjson
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/679143/9a684e89-59a2-a978-75b3-a1012f8b4ed8.png)

これが一般的なGeoJSONのファイルです。GeoJSONSeqであれば、赤枠で囲んだことを中心に見ればよいはずです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/679143/d9dac79f-7d89-8ae1-e6d5-a3c6d4c5fc32.png)

## Feature Service の地物 JSON と GeoJSONSeq の違いを考える
違いを考えたうえで、どのように処理したらいいか検討します。Esriさんから読んだJSON（便宜上esriと表現します）を、ベクトルタイル作成用のJSON (便宜上fと表現します)にするには、例えば以下のようなことが必要かと思われます。

* f.type の追加が必要(featureと指定すればよいだけ)
* f.propertiesとして、esri.feature.attributes をコピーする
* f.geometry　として
    * f.geometry.type をつける（esri.geometoryを参考にして、point,LineString, Polygon のいずれかを指定）
    * f.geometry.coordinatesとして、esri.geometory.paths[0]、esri.geometory.rings[0]などをつける。esri.geometoryではringsの中にring、pathsの中にpathがある構成になっているのかもしれません。MultipolygonやMultiLineでなければ気にせず最初のものを使えばいいのかなと思いますがどうだろう。(**後日追記:** LineStringはpathsの中に複数のpathをもっているものがあるので、Paths[0]だけで入れければLineStringで、マルチにしないといけないのは対応が必要です。Polygonも多分同様ですので更なる検討が必要です。rings[0]でなくてringsでとらないとダメでした。**→ FeatureServiceのpathsではGeoJSONのLineStringとMultiLineStringを含みうるし、FeatureServiceのringsではGeoJSONのPolygonとMultiPolygonを含みうるということだと思います。pointは今回扱ってないですが、たぶんそうです。**) 



なお、それならf=pjsonをf=geojsonにすればいいじゃかないかと思われるかと思いますが、JSONの構造は以下の通りで変わりませんでした。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/679143/f064a42b-be9f-6bbd-6588-a90d833b0972.png)



## nodejsでよめるか？加工できるか？の確認
nodejsで作業を進めます。どうやら node-fetch をいうモジュールを使うと、httpでとってきたJSONファイルを読み込めるようです。ただし、node-fetchはV3からrequire対応していないので、v2のものを使います。
読み込んできたesriさんのjsonをGeoJSONseqに似せてTippecanoe投入用に加工できるかやってみます。nodejsは得意ではありませんが、動けばいいというチャレンジ精神でやってみます。configモジュールを使って、コンフィグ情報のhjsonファイルとテストのJSファイルを作りました。ただし、いきなり全部はできないので　[FeatureServer/0/1?f=pjson](https://geoservices.un.org/arcgis/rest/services/Hosted/UN_Geodata_Stylized/FeatureServer/0/1?f=pjson) から [FeatureServer/0/10?f=pjson](https://geoservices.un.org/arcgis/rest/services/Hosted/UN_Geodata_Stylized/FeatureServer/0/10?f=pjson) をとってくる実験をしました。成果はテキストファイルにsteram（fsモジュール）で書き込みます。

```hjson:config/default.hjson
{
    url: https://geoservices.un.org/arcgis/rest/services/Hosted/UN_Geodata_Stylized/FeatureServer/
    outputText: output001.txt
    layers:[
        bnda
        bndl
    ]
    layerId:{
        bnda: 3
        bndl: 0
    }
    layerRecord:{
        bnda: 246
        bndl: 742
    }
}
```

```js:test01.js
//modules
const fetch = require('node-fetch')
const config = require('config')
const fs = require('fs')

//parameters
const featureServer = config.get('url')
const layers = config.get('layers')
const layerId = config.get('layerId')
const layerRecord = config.get('layerRecord')
const outputText = config.get('outputText')
//var data = []
const stream = fs.createWriteStream(outputText)

//actual code
async function get_feature(url,layerName3){
    const res = await fetch(url)
    const esri = await res.json()
    let f = new Object()
    f.type = 'feature'
    f.properties = {}
    f.geometry = {}
    f.geometry.coordinates = []
    f.tippecanoe = {}
    f.properties = esri.feature.attributes
    if(esri.feature.geometry.rings != undefined){
        f.geometry.type = 'Polygon'
        f.geometry.coordinates = esri.feature.geometry.rings   
    } else if (esri.feature.geometry.paths != undefined) {
        f.geometry.type = 'LineString'
        f.geometry.coordinates =esri.feature.geometry.paths[0]
    } else {
        f.geometry.type = 'Point'
        f.geometry.coordinates = esri.feature.geometry.points[0]
    }
    f.tippecanoe = {}
    f.tippecanoe.layer = layerName3 
    f.tippecanoe.maxzoom = 2
    f.tippecanoe.minzoom = 0
    delete f.properties.globalid  //delete unnnecesary attribution
    delete f.properties.globalid_1   //delete unnnecesary attribution
    delete f.properties.SHAPE__Length   //delete unnnecesary attribution
//    data.push(f)
    stream.write(JSON.stringify(f))
    stream.write(', \n')
}

async function getlayer(layer,count,layerName2){
    console.log('Starting the work!!!!!!')
    console.log(Date())
    console.log('----->')
    for (var i = 1; i < count + 1; i ++){
        var featureUrl = featureServer + layer + '/' + i + '?f=pjson'
        await get_feature(featureUrl,layerName2)
    }
}

//console.log(layerId[layers[0]])  //like "0" or "3"
//console.log(layerRecord[layers[0]]) //like "246" or "742"
//console.log(layers[0]) //like "bnda" or "bndl"
//getlayer(layerId[layers[0]],layerRecord[layers[0]],layers[0]).then(()=>{
getlayer(layerId[layers[0]],10,layers[0]).then(()=>{
//    console.log(JSON.parse(data[0]))
//    console.log(data[0])
      stream.end()
      console.log('end!!! Bye-bye')
      console.log(Date())
})
```


```bash
npm install --save config hjson fs
npm install --save node-fetch@2.x
node test1.js
```

と実行したら、一応、テキストとして以下のようなものを出力できるようになりました。

タイプの定義も大丈夫。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/679143/7b95e6b4-865b-5c2c-c8fc-957181d2fe76.png)
geometoryも意図した感じになっています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/679143/c39ee259-9e42-4836-aee4-fa2498b7d9de.png)
tippecanoe用の属性もつけられた。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/679143/d6532f86-9f8a-c8f4-81bb-ac4c31806a16.png)
一番最後にカンマ（,）が余分ですが後で考えます。


## 今後の見通し
今日は、時間がないのでここまでの実験で終了ですが、この先にやりたいことは以下のようなことです。
* 今後、各レイヤのすべてのレコードを取り込んでいきたいと思います。
  * ただし、レイヤ情報のJSONファイルをみたところ、最大のレコード数はあっても実際のレコード数がありません。とりあえず地道に数えて、それぞれ246と742と見つけたのですが、fetchしてエラーが返ってきたところでレコード数を確認するとか、レイヤ情報からレコード数を得るとか、もう少しいいやり方を将来見つけたいです。
  * また、一度たくさんのレコード数でためしたら、socket hang up のエラーが出ました。サーバーへのアクセスを少しコントロールした方がよいのかもしれません。queueを使うとか、エレガントな方法もあると思いますが、アクセスするのに少し時間をおいて（待ち時間を作って）やるとか考えたいです。その前に、そもそもエラーが出た時の処理を考えた方がよいのかもしれません。
* テキストファイルにしたJSON情報をTippecanoeに流しいれることもしたいと思います。なお、私の今回のデータはさほど大きくないので、途中で中間ファイルを作っても問題ありません。簡単のためにテキストファイルへの出力をワンステップおいてその先に進んでもいいのかなと思います。


# 後日追記
## コードの改善（１）：複数のレイヤ処理
test03.js ( https://github.com/ubukawa/cartotile/blob/main/test03.js )まで改善して、config/default.hjson に書いてあるレイヤとレコード数から、geojson sequenceのような感じでデータを出力できるようになりました。

サーバーの反応でエラーが返ってくるときもありますが（下図の上半分）、うまくいきました（下図の下半分）。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/679143/1862c105-6acf-1d69-2b69-7266cf495d08.png)

書き出したファイルですが、最後のカンマもとれました。実物はここにあります。 https://github.com/ubukawa/cartotile/blob/main/output002.json 
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/679143/f16cea2f-6e63-25dd-e989-f410e0ceba9a.png)

そうして出てきたjsonファイルをTippecanoeにかけました。
```bash
tippecanoe -e tile --projection=EPSG:3857 --no-tile-compression --no-feature-limit --no-tile-size-limit --drop-rate=1 output002.json
```

なんらかタイルデータはできています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/679143/7c67cb0a-016e-ad8c-f71c-28f3df120152.png)

## コードの改善（２）：マルチポリゴンなどの処理
タイルを読み込んだら、まだ作りが甘かったです。マルチラインの処理と、ポリゴンの座標の記述が甘かったのでうまく出ませんでした。もう一度挑戦します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/679143/ff28ffaa-1796-7727-7c7f-aa8e2c72452b.png)

pathsで複数がある例：　https://geoservices.un.org/arcgis/rest/services/Hosted/UN_Geodata_Stylized/FeatureServer/0/651?f=pjson
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/679143/638cc88a-f2d0-f047-44d6-3121c3e19e4c.png)

ringsで複数がある例：　
https://geoservices.un.org/arcgis/rest/services/Hosted/UN_Geodata_Stylized/FeatureServer/3/64?f=pjson
ringsは複数でも一つでも座標の構造がかわらないんですね。。一方で、GeoJSONのMultiPolygonはPolygonに比べて1階層深いかもしれません( https://www.rfc-editor.org/rfc/rfc7946#section-3.1.7 )。

以下の感じで書いていますが、MultiPolygonの処理の工夫が必要です。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/679143/0383cc88-c874-8525-a066-607beb4705f4.png)

しばらく考えて、ポリゴンの処理はこんな感じにしました↓
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/679143/2cd23a11-db10-4e2e-9def-5df276ad0e21.png)
こんどは大丈夫そうです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/679143/3e64bea4-ffa9-cde3-bcd1-a348583d121f.png)

##　ベクトルタイル化
修正したtest04.jsを実行して、そのあと出てきたファイルをtippiecanoeに入れました。
```
node test04.js
tippecanoe -e docs/tile --projection=EPSG:3857 --no-tile-compression --no-feature-limit --no-tile-size-limit --drop-rate=1 output004.json
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/679143/77a98b53-40e5-4b18-4be5-345505f5a901.png)

タイルができて、GitHubページでみるとこんな感じでした。
https://ubukawa.github.io/cartotile/index.html#0.38/0/23.9
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/679143/42906786-f4c3-02d3-ab75-f52cf2b5ad6e.png)


# まとめ
今回、EsriサーバーのFeature ServiceからJSONファイルを取り出すことを試してみました。Layer中にいくつレコード（地物）があるかわからない、JSONの構造を変えないとベクトルタイル変換などに使えない、などの課題もありますが、feature serviceも素晴らしいサービスで、いろいろなデータの利用法が広がりそうだと感じました。

# 参考
参考にしたページは本文中にリンクしています。
また、今回の私のレポジトリは以下のところにあります。

https://github.com/ubukawa/cartotile