onyxレポジトリを理解する

# はじめに
[こちらの記事](https://qiita.com/k96mz/items/668bee0c6d751a444b5c)でnode.jsでベクトルタイルサーバを作成するためのコードについて記載しました。上記記事では、一つのMBTilesファイルを想定していましたが、ホスティングするデータが大きくなってくると、データを複数のMBTilesファイルに分割してホスティングすることがあります。
[un-vector-tile-toolkit/onyx](https://github.com/un-vector-tile-toolkit/onyx) では、複数に分けられたMBTilesファイルからベクトルタイル配信を行うためのルーティングが実装されています。本記事では、onyxのコード解説を行い、実際にコードを実行してみます。
作業レポジトリは[こちら](https://github.com/k96mz/20241022onyx)です。

# app.jsのコード

``` javascript: app.js
const config = require('config')
const fs = require('fs')
const express = require('express')
const spdy = require('spdy')
const cors = require('cors')
const morgan = require('morgan')
const MBTiles = require('@mapbox/mbtiles')
const TimeFormat = require('hh-mm-ss')
const winston = require('winston')
const DailyRotateFile = require('winston-daily-rotate-file')

// config constants
const morganFormat = config.get('morganFormat')
const htdocsPath = config.get('htdocsPath')
const privkeyPath = config.get('privkeyPath')
const fullchainPath = config.get('fullchainPath')
const port = config.get('port') 
const defaultZ = config.get('defaultZ')
const mbtilesDir = config.get('mbtilesDir')
const fontsDir = config.get('fontsDir')
const logDirPath = config.get('logDirPath')

// global variables
let mbtilesPool = {}
let tz = config.get('tz')
let busy = false

// logger configuration
const logger = winston.createLogger({
  transports: [
    new winston.transports.Console(),
    new DailyRotateFile({
      filename: `${logDirPath}/onyx-%DATE%.log`,
      datePattern: 'YYYY-MM-DD'
    })
  ]
})

logger.stream = {
  write: (message) => { logger.info(message.trim()) }
}

// app
const app = express()
app.use(cors())
app.use(morgan(morganFormat, {
  stream: logger.stream
}))
app.use(express.static(htdocsPath))

const getMBTiles = async (t, z, x, y) => {
  let mbtilesPath = ''
  if (!tz[t]) tz[t] = defaultZ
  if (z < tz[t]) {
    mbtilesPath = `${mbtilesDir}/${t}/0-0-0.mbtiles`
  } else {
    mbtilesPath =
      `${mbtilesDir}/${t}/${tz[t]}-${x >> (z - tz[t])}-${y >> (z - tz[t])}.mbtiles`
  }
  return new Promise((resolve, reject) => {
    if (mbtilesPool[mbtilesPath]) {
      resolve(mbtilesPool[mbtilesPath].mbtiles)
    } else {
      if (fs.existsSync(mbtilesPath)) {
        new MBTiles(`${mbtilesPath}?mode=ro`, (err, mbtiles) => {
          if (err) {
            reject(new Error(`${mbtilesPath} could not open.`))
          } else {
            mbtilesPool[mbtilesPath] = {
              mbtiles: mbtiles, openTime: new Date()
            }
            resolve(mbtilesPool[mbtilesPath].mbtiles)
          }
        })
      } else {
        reject(new Error(`${mbtilesPath} was not found.`))
      }
    }
  })
}

const getTile = async (mbtiles, z, x, y) => {
  return new Promise((resolve, reject) => {
    mbtiles.getTile(z, x, y, (err, tile, headers) => {
      if (err) {
        reject()
      } else {
        resolve({tile: tile, headers: headers})
      }
    })
  })
}

app.get(`/zxy/:t/:z/:x/:y.pbf`, async (req, res) => {
  busy = true
  const t = req.params.t
  const z = parseInt(req.params.z)
  const x = parseInt(req.params.x)
  const y = parseInt(req.params.y)
  getMBTiles(t, z, x, y).then(mbtiles => {
    getTile(mbtiles, z, x, y).then(r => {
      if (r.tile) {
        res.set('content-type', 'application/vnd.mapbox-vector-tile')
        res.set('content-encoding', 'gzip')
        res.set('last-modified', r.headers['Last-Modified'])
        res.set('etag', r.headers['ETag'])
        res.send(r.tile)
        busy = false
      } else {
        res.status(404).send(`tile not found: /zxy/${t}/${z}/${x}/${y}.pbf`)
        busy = false
      }
    }).catch(e => {
      res.status(404).send(`tile not found: /zxy/${t}/${z}/${x}/${y}.pbf`)
      busy = false
    })
  }).catch(e => {
    res.status(404).send(`mbtiles not found for /zxy/${t}/${z}/${x}/${y}.pbf`)
  })
})

app.get(`/fonts/:fontstack/:range.pbf`, (req, res) => {
  res.set('content-type', 'application/x-protobuf')
  res.set('content-encoding', 'gzip')
  for(const fontstack of req.params.fontstack.split(',')) {
    const path = `${fontsDir}/${fontstack}/${req.params.range}.pbf.gz`
    if (fs.existsSync(path)) {
      res.send(fs.readFileSync(path))
      return
    }
  }
  res.status(404).send(`font not found: ${req.params.fontstack}/${req.params.range}`)
})

spdy.createServer({
  key: fs.readFileSync(privkeyPath),
  cert: fs.readFileSync(fullchainPath)
}, app).listen(port)
```

## コード解説

```javascript:app.js
let tz = config.get('tz')
```

config/default.hjsonにおいて、例として以下のように記載されています。
  tz: {
    tapioca: 6
  }
つまり、
tz = { tapioca: 6 }
などとなります。

```javascript:app.js
if (!tz[t]) tz[t] = defaultZ
```
ここでの「t」は「mbtilesPath = \`\\${mbtilesDir}/${t}/0-0-0.mbtiles\`」などとフォルダ名として利用されます。tは上記で見た通り、「tapioca」等です。
もし、mbtilesファイルの上位のフォルダが存在しない場合（つまり、tz[tapioca]などが定義されていない場合）には、tz[t]の値を 6 ( = defaultZ)と設定しています。


``` javascript: app.js
if (z < tz[t]) {
  mbtilesPath = `${mbtilesDir}/${t}/0-0-0.mbtiles`;
} else {
  mbtilesPath = `${mbtilesDir}/${t}/${tz[t]}-${x >> (z - tz[t])}-${
    y >> (z - tz[t])
  }.mbtiles`;
}
```

z（リクエストのあったズームレベル）がtz[t]より小さい時（つまりzが5以下の時）は、mbtilesPathが以下のようになります。
 mbtilesPath = /somewhere/mbtiles/tapioca/0-0-0.mbtiles

zがtz[t]以上の時（つまりzが6以上の時）は以下のようになります。

${x >> (z - tz[t])}
については、例えば z が 8 だとすると、
z - tz[t] = 8 - 6 = 2
となります。
この場合、
x >> 2
となり、xを右に2ビットだけシフトさせます。
つまり、xの値を2*2 = 4で割った値となります。

ビットシフト演算子 >> については、[こちらの記事](https://qiita.com/k96mz/items/49f7b06f483d40576af1)にまとめました。

例えば、つくば市辺りのタイルを考えて、z = 9, x = 455, y = 200とすると、
\\${x >> (z - tz[t])} = 455 >> 3 = 455 ÷ 8 = 56.875
\\${y >> (z - tz[t])} = 200 >> 3 = 200 ÷ 8 = 25
となります。

実際にタイル座標確認ページで確認しました。
ズームレベル９
![スクリーンショット 2024-10-22 16.41.02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3684675/31f47ea0-eec0-bab0-204b-98032378dea7.png)

ズームレベル6
![スクリーンショット 2024-10-22 16.41.14.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3684675/593d7e49-ee53-ec49-9967-587e1c60e544.png)

確かに
z = 9, x = 455, y = 200
のタイルは、
z = 6, x = 56, y = 25
のタイルに含まれています。

つまり、このコードでは大きなズームレベルでのタイル座標を、tz[t]のズームレベルでの適切なタイル座標に変換しています。



``` javascript: app.js
app.get(`/fonts/:fontstack/:range.pbf`, (req, res) => {
  res.set('content-type', 'application/x-protobuf')
  res.set('content-encoding', 'gzip')
  for(const fontstack of req.params.fontstack.split(',')) {
    const path = `${fontsDir}/${fontstack}/${req.params.range}.pbf.gz`
    if (fs.existsSync(path)) {
      res.send(fs.readFileSync(path))
      return
    }
  }
  res.status(404).send(`font not found: ${req.params.fontstack}/${req.params.range}`)
})
```

ルート設定 (/fonts/:fontstack/:range.pbf)
- このエンドポイントは /fonts/ に続く、2 つのパラメータ :fontstack と :range.pbf を受け取ります。
- fontstack: フォントの名前やスタイルのリスト（カンマ区切り）
- range.pbf: プロトコルバッファ形式のフォントファイル（.pbf拡張子付き）の特定の範囲（フォントの文字コード範囲など）
例: /fonts/Roboto-Bold,Arial/0-255.pbf

for文のところでは、フォントファイルを探す処理を行っていますが、詳しいことは良くわかりませんでした。分かったら、また追記したいと思います。

# ローカルでホスティングしてみる

まずは、以下のようにして必要なモジュールをインストールします。
```
npm install @mapbox/mbtiles config cors express hh-mm-ss hjson morgan spdy winston winston-daily-rotate-file
```

先日、[こちらの記事](https://qiita.com/k96mz/items/be74755c97f51f4d3e2b)で作成したmbtilesをローカルにおいてホスティングしてみます。

:::note info
エラー
if (fs.existsSync(mbtilesPath)) {
上記部分の処理がうまく出来ずに時間がかかりました。原因としては、default.hjsonにおいて、
mbtilesDir: /somewhere/mbtiles
としていたことです。正しくは、
somewhere/mbtiles
とする必要があります。つまり、最初のスラッシュが不要でした。
:::

スタイルファイルはbndlだけ適当に用意しました。

node app-local.js
として、
http\://localhost:3000/map/mapO-localmbtiles.html
を見てみたところ以下の通り表示されました。

![スクリーンショット 2024-10-24 10.34.10.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3684675/97d91b8c-cf8f-f478-f96e-b62afdcb0bbc.png)

# サーバでホスティングする
以前に[こちらの記事](https://qiita.com/k96mz/items/e33f493be15e9253e2ff)でサーバでのホスティングを記載しました。今回は、こちらのサーバを用いて、onyxレポジトリのコードをホスティング出来るか試します。
作成したファイル群をFileZillaを用いてサーバに移動させて、npm installでモジュールをインストールしてから、node app.jsとします。
mapOs-mbtiles.html
を見てみると、chromeでは５回に１回くらいの割合で正常に表示されました。なぜか今回は、Edgeでは表示出来ませんでした。

# まとめ
データを複数のMBTilesファイルに分割してホスティングしているonyxレポジトリのコードを解説して、レンタルサーバで実際にホスティングをしました。ブラウザによって不安定な挙動となることが分かりました。

# Reference

https://qiita.com/k96mz/items/668bee0c6d751a444b5c

https://github.com/un-vector-tile-toolkit/onyx

https://qiita.com/k96mz/items/49f7b06f483d40576af1

https://maps.gsi.go.jp/development/tileCoordCheck.html#6/36.173/139.285

https://qiita.com/k96mz/items/e33f493be15e9253e2ff