produce-gsc-small（PostGISから小縮尺のベクトルタイル作成）を理解する

# はじめに
[こちらの記事](https://qiita.com/k96mz/items/be62463594b28a21e73e)でPostGISからベクトルタイル作成に関する記事を記載しました。この記事では、実際の運用で使用されている[produce-gsc-smallレポジトリ](https://github.com/unvt/produce-gsc-6/tree/main/produce-gsc-small)の中身について解説します。こちらのレポジトリでは、UNデータが入っているun_baseデータベースからズームレベル0~5のベクトルタイルを作成するコード、オープンストリートマップデータが入っているosm_baseデータベースからズームレベル0~5のベクトルタイルを作成するコードの２つが入っています。作成するベクトルタイルはどちらもmbtiles形式で保存されます。

本記事の作業レポジトリは[こちら](https://github.com/k96mz/20241010produce-gsc-small)です。

# ズームレベル0~5のUNデータのベクトルタイルを作成する

UNデータが入っているun_baseデータベースからズームレベル0~5のベクトルタイルを作成するコードはindex_un-s.jsに記載されています。以下の解説では、以前の記事で記載したものと重複する部分は飛ばします。
bylineモジュールは実際には使用されていないので、コメントアウトする必要があります。

```javascript:index_un-s.js
// libraries
const config = require("config");
const { spawn } = require("child_process");
const byline = require("byline");
const fs = require("fs");
const Queue = require("better-queue");
const pretty = require("prettysize");
const tilebelt = require("@mapbox/tilebelt");
const TimeFormat = require("hh-mm-ss");
const { Pool, Query } = require("pg");
const Spinner = require("cli-spinner").Spinner;
const winston = require("winston");
const DailyRotateFile = require("winston-daily-rotate-file");
const modify = require("./modify.js");

// config constants
const host = config.get("un-l.host");
const port = config.get("un-l.port");
const wtpsThreshold = config.get("wtpsThreshold");
const monitorPeriod = config.get("monitorPeriod");
const Z = config.get("un-s.Z");
const dbUser = config.get("un-l.dbUser");
const dbPassword = config.get("un-l.dbPassword");
const relations = config.get("un-s.relations");
const defaultDate = new Date(config.get("defaultDate"));
const mbtilesDir = config.get("un-s.mbtilesDir");
const logDir = config.get("logDir");
const propertyBlacklist = config.get("un-s.propertyBlacklist");
const conversionTilelist = config.get("un-s.conversionTilelist");
const spinnerString = config.get("spinnerString");
const fetchSize = config.get("fetchSize");
const tippecanoePath = config.get("tippecanoePath");

// global configurations
Spinner.setDefaultSpinnerString(spinnerString);
winston.configure({
  level: "silly",
  format: winston.format.simple(),
  transports: [
    new DailyRotateFile({
      filename: `${logDir}/produce-un-small-%DATE%.log`,
      datePattern: "YYYY-MM-DD",
      maxSize: "20m",
      maxFiles: "14d",
    }),
  ],
});

// global variable
let idle = true;
let wtps;
let modules = {};
let sar;
let pools = {};
let productionSpinner = new Spinner();
let moduleKeysInProgress = [];

const isIdle = () => {
  return idle;
};

// all scores are zero because we cannot login as unix user
const getScores = async () => {
  return new Promise(async (resolve, reject) => {
    // identify modules to update
    let oldestDate = new Date();

    //Replaced loop (based on the list)
    for (const moduleKey of conversionTilelist) {
      const path = `${mbtilesDir}/${moduleKey}.mbtiles`;
      let mtime = defaultDate;
      let size = 0;
      if (fs.existsSync(path)) {
        let stat = fs.statSync(path);
        mtime = stat.mtime;
        size = stat.size;
      }
      oldestDate = oldestDate < mtime ? oldestDate : mtime;
      modules[moduleKey] = {
        mtime: mtime,
        size: size,
        score: 0,
      };
    }

    resolve();
  });
};

const iso = () => {
  return new Date().toISOString();
};

const noPressureWrite = (downstream, f) => {
  return new Promise((res) => {
    if (downstream.write(`\x1e${JSON.stringify(f)}\n`)) {
      res();
    } else {
      downstream.once("drain", () => {
        res();
      });
    }
  });
};

const fetch = (client, database, table, downstream) => {
  return new Promise((resolve, reject) => {
    let count = 0;
    let features = [];
    client
      .query(new Query(`FETCH ${fetchSize} FROM cur`))
      .on("row", (row) => {
        let f = {
          type: "Feature",
          properties: row,
          geometry: JSON.parse(row.st_asgeojson),
        };
        delete f.properties.st_asgeojson;
        f.properties._database = database;
        f.properties._table = table;
        count++;
        f = modify(f);
        if (f) features.push(f);
      })
      .on("error", (err) => {
        console.error(err.stack);
        reject();
      })
      .on("end", async () => {
        for (f of features) {
          try {
            await noPressureWrite(downstream, f);
          } catch (e) {
            reject(e);
          }
        }
        resolve(count);
      });
  });
};

const dumpAndModify = async (bbox, relation, downstream, moduleKey) => {
  return new Promise((resolve, reject) => {
    const startTime = new Date();
    //    const [database, table] = relation.split('::')
    const [database, schema, table] = relation.split("::");
    if (!pools[database]) {
      pools[database] = new Pool({
        host: host,
        user: dbUser,
        port: port,
        password: dbPassword,
        database: database,
      });
    }
    pools[database].connect(async (err, client, release) => {
      if (err) throw err;
      let sql = `
        SELECT column_name FROM information_schema.columns 
        WHERE table_name='${table}' AND table_schema='${schema}' ORDER BY ordinal_position`;
      let cols = await client.query(sql);
      cols = cols.rows.map((r) => r.column_name).filter((r) => r !== "geom");
      cols = cols.filter((v) => !propertyBlacklist.includes(v));
      // ST_AsGeoJSON(ST_Intersection(ST_MakeValid(${table}.geom), envelope.geom))
      cols.push(`ST_AsGeoJSON(${schema}.${table}.geom)`);
      await client.query(`BEGIN`);
      sql = `
        DECLARE cur CURSOR FOR 
        WITH
          envelope AS (SELECT ST_MakeEnvelope(${bbox.join(", ")}, 4326) AS geom)
        SELECT 
          ${cols.toString()}
        FROM ${schema}.${table}
        JOIN envelope ON ${schema}.${table}.geom && envelope.geom
      `;
      cols = await client.query(sql);
      try {
        while ((await fetch(client, database, table, downstream)) !== 0) {}
      } catch (e) {
        reject(e);
      }
      await client.query(`COMMIT`);
      winston.info(`${iso()}: finished ${relation} of ${moduleKey}`);
      release();
      resolve();
    });
  });
};

const sleep = (wait) => {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve();
    }, wait);
  });
};

const queue = new Queue(
  async (t, cb) => {
    const startTime = new Date();
    const moduleKey = t.moduleKey;
    const queueStats = queue.getStats();
    const [z, x, y] = moduleKey.split("-").map((v) => Number(v));
    const bbox = tilebelt.tileToBBOX([x, y, z]);
    const tmpPath = `${mbtilesDir}/part-${moduleKey}.mbtiles`;
    const dstPath = `${mbtilesDir}/${moduleKey}.mbtiles`;

    moduleKeysInProgress.push(moduleKey);
    productionSpinner.setSpinnerTitle(moduleKeysInProgress.join(", "));

    const tippecanoe = spawn(
      tippecanoePath,
      [
        "--quiet",
        "--no-feature-limit",
        "--no-tile-size-limit",
        "--force",
        "--simplification=2",
        "--drop-rate=1",
        `--minimum-zoom=${Z}`,
        "--maximum-zoom=5",
        "--base-zoom=5",
        "--hilbert",
        `--clip-bounding-box=${bbox.join(",")}`,
        `--output=${tmpPath}`,
      ],
      { stdio: ["pipe", "inherit", "inherit"] }
    );
    tippecanoe.on("exit", () => {
      fs.renameSync(tmpPath, dstPath);
      moduleKeysInProgress = moduleKeysInProgress.filter(
        (v) => !(v === moduleKey)
      );
      productionSpinner.stop();
      process.stdout.write("\n");
      const logString = `${iso()}: [${queueStats.total + 1}/${
        queueStats.peak
      }] process ${moduleKey} (score: ${modules[moduleKey].score}, ${pretty(
        modules[moduleKey].size
      )} => ${pretty(fs.statSync(dstPath).size)}) took ${TimeFormat.fromMs(
        new Date() - startTime
      )} wtps=${wtps}.`;
      winston.info(logString);
      console.log(logString);
      if (moduleKeysInProgress.length !== 0) {
        productionSpinner.setSpinnerTitle(moduleKeysInProgress.join(", "));
        productionSpinner.start();
      }
      return cb();
    });

    productionSpinner.start();
    for (relation of relations) {
      while (!isIdle()) {
        winston.info(
          `${iso()}: short break due to heavy disk writes (wtps=${wtps}).`
        );
        await sleep(5000);
      }
      try {
        await dumpAndModify(bbox, relation, tippecanoe.stdin, moduleKey);
      } catch (e) {
        winston.error(e);
        cb(true);
      }
    }
    tippecanoe.stdin.end();
  },
  {
    concurrent: config.get("concurrentS"),
    maxRetries: config.get("maxRetries"),
    retryDelay: config.get("retryDelay"),
  }
);

const queueTasks = () => {
  let moduleKeys = Object.keys(modules);
  moduleKeys.sort((a, b) => modules[b].score - modules[a].score);
  for (let moduleKey of moduleKeys) {
    //for (let moduleKey of ['6-37-31', '6-38-31', '6-37-32', '6-38-32']) { //// TEMP
    //if (modules[moduleKey].score > 0) {
    queue.push({
      moduleKey: moduleKey,
    });
    //}
  }
};

// shutdown this system
const shutdown = () => {
  winston.info(`${iso()}: production system shutdown.`);
  console.log("** production system for un-s shutdown! **");
  //  sar.kill()
  process.exit(0);
};

const main = async () => {
  winston.info(`${iso()}: un-s production started.`);
  await getScores();
  queueTasks();
  queue.on("drain", () => {
    shutdown();
  });
};

main();
```

まとめ: 実行順
	1.	main() 実行
	2.	getScores() で処理対象のモジュールを決定
	3.	queueTasks() でキューにタスクを追加
	4.	queue が非同期で処理を開始
	5.	dumpAndModify() でデータ取得・変換
	6.	Tippecanoe を実行し、MBTiles を作成
	7.	すべての処理が完了すると shutdown() でシステム終了 


## コードの解説

```javascript:index_un-s.js
const getScores = async () => {
  return new Promise(async (resolve, reject) => {
    // identify modules to update
    let oldestDate = new Date();

    //Replaced loop (based on the list)
    for (const moduleKey of conversionTilelist) {
      const path = `${mbtilesDir}/${moduleKey}.mbtiles`;
      let mtime = defaultDate;
      let size = 0;
      if (fs.existsSync(path)) {
        let stat = fs.statSync(path);
        mtime = stat.mtime;
        size = stat.size;
      }
      oldestDate = oldestDate < mtime ? oldestDate : mtime;
      modules[moduleKey] = {
        mtime: mtime,
        size: size,
        score: 0,
      };
    }

    resolve();
  });
};
```
該当する .mbtiles ファイルが存在するかを確認し、そのファイルの最終更新日時 (mtime) とサイズ (size) を取得します。各モジュールのデータを modules というオブジェクトに格納し、後で使用できるようにしています。
変数oldestDate を使って、ループを回しながらファイルの更新日時がより古いかどうかを比較し、最も古い日時を oldestDate に保持します。おそらく、いつ更新したのか分かるようにしておきたいという理由だと思います。

・fs.statSync(path).mtime
mbtilesファイルの「最終更新日時」を示しています。[こちらに解説](https://nodejs.org/api/fs.html#statsmtime)があります。例えば以下のように表示されます。
```
2024-10-10T16:38:26.922Z
```
new Date()についても、上記と同様の形式で出力されます。

・oldestDate = oldestDate < mtime ? oldestDate : mtime;
oldestDate が mtime よりも古い場合、oldestDate の値をそのまま保持します。

```javascript:index_un-s.js
cols = cols.filter((v) => !propertyBlacklist.includes(v));
```
sqlで得たカラム名が、配列propertyBlacklist（除外すべきカラムリスト）に含まれている場合はそれを除外します。

```javascript:index_un-s.js
sql = `
  DECLARE cur CURSOR FOR 
  WITH envelope AS 
  (SELECT ST_MakeEnvelope(${bbox.join(', ')}, 4326) AS geom)
  SELECT ${cols.toString()} FROM ${schema}.${table}
  JOIN envelope
  ON ${schema}.${table}.geom && envelope.geom
`;
```

WITH句は、副問い合わせ（サブクエリ）に名前を付けられる機能で、一時テーブルみたいなものです。ここではenvelopeというサブクエリが定義されています。この部分では、ST_MakeEnvelope 関数を使って、指定した bbox（バウンディングボックス）で地理的なエンベロープ（四角形の範囲）を作成しています。このエンベロープは、緯度経度（EPSG:4326）で定義されています。bbox.join(", ") は、バウンディングボックスの配列（[xmin, ymin, xmax, ymax]）を文字列として結合し、SQLのクエリに埋め込んでいます。結果を geom という列として提供しています。

ST_MakeEnvelope 関数の説明は[こちら](https://postgis.net/docs/ST_MakeEnvelope.html)にあります。例えば、
ST_MakeEnvelope(10, 10, 11, 11, 4326)
のように使用します。

JOINに関するイメージとしては、
SELECT \\${cols.toString()} FROM \\${schema}.\\${table}
で作成されたテーブルと、envelopeテーブルを比較して
条件「\\${schema}.${table}.geom && envelope.geom」
を満たす部分のみ抽出するというものです。
 && 演算子は、PostGISの空間演算子で、「2つのジオメトリが重なり合っているかどうか」をチェックします。つまり、このクエリでは、テーブル内のジオメトリと、先ほど作成したバウンディングボックスの範囲が重なっているレコードだけを選択します。
&& 演算子の説明は[こちら](https://postgis.net/docs/geometry_overlaps.html)にあります。

pgAdmin 4を利用して、ローカルにあるデータで実験してみました。
```
WITH envelope AS 
 (SELECT ST_MakeEnvelope(-105, 40.3, -104.8, 40.5, 4326) AS test)
SELECT * FROM baea_nests
JOIN envelope
ON baea_nests.geom && envelope.test
```
クエリを上記のように実行すると以下の通り２つのデータが取得出来ました。このクエリを解説すると、経度-105 ~ -104.8の間、緯度40.3 ~ 40.5の間にあるバウンディングボックスと重なりがあるレコードを「baea_nests」テーブルから抽出するというものです。バウンディングボックスのテーブル名を「test」としています。


![スクリーンショット 2024-10-14 13.51.13.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3684675/4488d1e6-a33d-904c-43ca-ce4b9c2bfca4.png)

QGIS上で該当データを見てみると以下の通りです。

![スクリーンショット 2024-10-14 13.53.12.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3684675/604db212-3c37-7bda-a33e-2ada1549ec26.png)

```javascript:index_un-s.js
const queueStats = queue.getStats();
```

better-queueモジュールのgetStats()は、キューの現在の状態に関する統計情報を取得するためのメソッドです。これを使用すると、キューのパフォーマンスやステータスを確認できます。
getStats()の説明は[こちらのページ](https://www.npmjs.com/package/better-queue)にあります。

getStats().total: キューに追加されたジョブの総数
getStats().peak: キューに一度に追加されたジョブの最大数。つまり、キューが最も多くのジョブを処理する準備ができていたピーク時の状態を表しています。この数値を監視することで、システムがどのくらいの負荷に耐えたのか、またリソースの最適化が必要かどうかを判断する手助けとなります。


```javascript:index_un-s.js
const [z, x, y] = moduleKey.split('-').map(v => Number(v));
const bbox = tilebelt.tileToBBOX([x, y, z]);
```

moduleKey という文字列は "z-x-y" の形式（ex. 0-0-0）となっていて、それをNumber型に変換して、それぞれz, x, yに代入しています。
そして、[tileToBBOX メソッド](https://github.com/mapbox/tilebelt)を使って、[x, y, z] というタイル座標からバウンディングボックスを計算します。bbox はその結果の配列です。
例えば、tileToBBOX([10, 15, 8])だと得られる配列は、[ -165.9375, 82.67628497834903, -164.53125, 82.8533822917608 ]
となります。

```javascript:index_un-s.js
const tippecanoe = spawn(
  tippecanoePath,
  [
    '--quiet',
    '--no-feature-limit',
    '--no-tile-size-limit',
    '--force',
    '--simplification=2',
    '--drop-rate=1',
    `--minimum-zoom=${Z}`,
    '--maximum-zoom=5',
    '--base-zoom=5',
    '--hilbert',
    `--clip-bounding-box=${bbox.join(',')}`,
    `--output=${tmpPath}`,
  ],
  { stdio: ['pipe', 'inherit', 'inherit'] }
);
```

tippecaneoの--clip-bounding-boxオプションは、以下のように使用します。
--clip-bounding-box = minlon, minlat, maxlon, maxlat

指定されたバウンディングボックスの内側にある地物のみ使用します。大量の地理データをタイル化する際に、特定の範囲に限定したデータを生成するために有用です。また、外側のジオメトリを切り取ることで、余分なデータが除かれ、データサイズを小さくすることもできます。
ただし、sql文を実行する際にすでに、バウンディングボックス外のデータは除外されているはずなので、この記載は冗長かもしれません。しかし、PostGISのクエリにおいて、境界上のデータなどはどうなっているのか不明なため、tippecanoeでも--clip-bounding-boxオプションをつけておくのが良さそうな気がします。

```javascript:index_un-s.js
const logString = `${iso()}: [${queueStats.total + 1}/${
  queueStats.peak
}] process ${moduleKey} (score: ${modules[moduleKey].score}, ${pretty(
  modules[moduleKey].size
)} => ${pretty(fs.statSync(dstPath).size)}) took ${TimeFormat.fromMs(
  new Date() - startTime
)} wtps=${wtps}.`;
```

・\\${queueStats.total + 1}/${queueStats.peak}:
queueStats.total は、これまでに処理されたタスクの総数を示しています。+1は現在のタスクを含めるためです。
queueStats.peak は、キューに存在したタスクの最大数（ピーク値）です。
これにより、タスクの進捗が [現在のタスク番号/ピーク時のタスク数] という形式でログに表示されます。

・${modules[moduleKey].score}
このコードでは、これは常に0となります。

・\\${pretty(modules[moduleKey].size)} =>　${pretty(fs.statSync(dstPath).size)
modules[moduleKey].size は以前のファイルサイズを表しており、pretty(fs.statSync(dstPath).size) は新しく作成されたMBTilesファイル（dstPathに保存されたファイル）のサイズを表しています。この2つの値を比較して、サイズがどう変わったかをログで確認することができます。

・${wtps}は定義されていません。バグかと思われます。実行するとundefinedとなり、プログラムが止まることはありませんでした。

```javascript:index_un-s.js
let moduleKeys = Object.keys(modules);
moduleKeys.sort((a, b) => modules[b].score - modules[a].score);
```
Object.keys(modules)は、modules オブジェクトの全てのプロパティ名（キー）を配列として取得します。例えば、modules オブジェクトが以下のようになっている場合の例を示します。

```
const modules = {
  '0-0-0': { score: 10 },
  '1-1-1': { score: 20 },
  '2-2-2': { score: 5 },
};
```
Object.keys(modules) は ['0-0-0', '1-1-1', '2-2-2'] を返します。


moduleKeys.sort((a, b) => modules[b].score - modules[a].score):
sort メソッドを使って moduleKeys 配列をソートしています。この比較関数では、modules[b].score と modules[a].score の値を引き算しています。ここで、b のスコアから a のスコアを引くことにより、スコアが高いものが前に来るようにソートされます。
例えば、前述の modules の場合、ソート後は ['1-1-1', '0-0-0', '2-2-2'] になります。これにより、スコアが高いモジュールが先に処理されます。

```javascript:index_un-s.js
// shutdown this system
const shutdown = () => {
  winston.info(`${iso()}: production system shutdown.`);
  console.log("** production system for un-s shutdown! **");
  //  sar.kill()
  process.exit(0);
};
```

process.exit(0) は、Node.jsのプロセスを正常終了させます。引数の 0 は終了ステータスコードで、0 は「正常終了」を意味します。これにより、Node.jsアプリケーション全体が終了し、すべての処理が停止します。

※その他
参考にしているレポジトリのpackage.jsonに"better-queue"がありませんが、ここでは必要だと思います。

## default.hjson
設定ファイルについて、必要な部分のみ抽出したものを記載しています。ローカル用です。

```text:config/default.hjson
{
  un-s: { 
    Z: 0
    relations: [
      natural_earth::public::ne_110m_coastline
      natural_earth::public::ne_110m_rivers_lake_centerlines
      natural_earth::public::ne_110m_land
    ]
    mbtilesDir: produce-gsc-small/un-s-tile
    propertyBlacklist: [
      description
      fid
      fid_1
      globalid
      id
      objectid_1
      osm_id
      pseudoarea
    ]
    conversionTilelist: [
      0-0-0
    ]
  }
  un-l: {
    host: localhost
    port: 5432
    dbUser: postgres
    dbPassword: postgres
  }
  wtpsThreshold: 50
  monitorPeriod: 10
  concurrentS: 1
  maxRetries: 5
  retryDelay: 5000
  logDir: log
  defaultDate: 2021-01-20
  spinnerString: 18
  fetchSize: 30000
  tippecanoePath: tippecanoe

  htdocsPath: produce-gsc-small/htdocs
  port: 3000
  logDirPath: produce-gsc-small/loglocal
  morganFormat: tiny
  mbtilesDir: produce-gsc-small/un-s-tile
}
```

:::note info
config/default.hjsonを置く場所
configモジュールが設定ファイルを探す際のデフォルトの動作は、現在の実行ディレクトリ（Node.jsを実行している場所）に基づいて設定ファイルを探します。
20241010produce-gsc-small/config/default.hjson
となっている場合には、20241010produce-gsc-smallレポジトリ上で、
node produce-gsc-small/index_un-s.js
とする必要があります。
:::

## ローカルデータを使用して地図を表示してみる
まずは、ローカルのデータ（Natural Earth）を使用して地図を表示したいと思います。

### ベクトルタイル作成
きちんとベクトルタイルが作成出来ているのかを確認するために、確認が簡単なPMTilesファイル形式のベクトルタイルを作成しました。index_un-s.jsをコピーして、index_un-s_pmtiles.jsとして実行します。ついでに、index_un-s_pbf.jsというファイルも用意しpbfファイル形式のベクトルタイルも作成しました。
作成したベクトルタイルのURLは以下です。

・pmtiles形式のベクトルタイル
https\://k96mz.github.io/20241010produce-gsc-small/produce-gsc-small/un-s-tile/0-0-0naturalEarth.pmtiles

以下のような地図が見られました。
![スクリーンショット 2024-10-15 14.59.13.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3684675/3fd389e3-0c6f-95fc-13c6-b6129ba6f360.png)


・pbfファイル形式のベクトルタイル
https\://k96mz.github.io/20241010produce-gsc-small/produce-gsc-small/pbfFiles_naturalEarth/{z}/{x}/{y}.pbf

もちろんmbtiles形式のベクトルタイルも作成しました。

### スタイルファイルの準備
スタイルファイルは[こちらの記事](https://qiita.com/k96mz/items/588a5241327f51cb5f9f)のものを使用します。

### MBTilesファイルのホスティング
[こちらの記事](https://qiita.com/k96mz/items/668bee0c6d751a444b5c)でmbtilesでのホスティング方法についてまとめているので、参照しながら作業を進めます。

参照記事のapp.jsと./routes/VT.jsをコピーしてきます。次に、default.hjsonに必要な設定の追記、また必要なモジュールのインストールを行います。htdocs内にmap-mbtiles.htmlを作成します。また必要なファイル(maplibre-gl.css、maplibre-gl.js、maplibre-gl.js.map)もコピーしてきます。htmlファイルの参照先のstyle-mbtiles.jsonも作成して、中身は先程言及したスタイルファイルの一部を利用します。
スタイルファイルにおいて、ベクトルタイル参照先は以下に設定します。
"tiles": ["http://localhost:3000/VT/zxy/0-0-0naturalEarth/{z}/{x}/{y}.pbf"]

```
node produce-gsc-small/app.js
```
としてローカルサーバを起動し、以下のURLに行くと無事に地図が見られました。
http://localhost:3000/map-mbtiles.html

![スクリーンショット 2024-10-15 11.43.00.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3684675/33f4e284-54ec-1d74-6628-6d9487f2e389.png)

## app.jsの設定
今まで、app.jsにおいて以下の通りの設定としていました。
```
app.use(express.static(`${__dirname}/${htdocsPath}`));
```

しかし、これではエラーが発生したので、以下のように設定し直しました。
```
app.use(express.static(`${htdocsPath}`));
```

:::note info
\\${__dirname}
.（ドット）は自分が今いるcurrent directoryを表しますが、スクリプトがある場所とcurrent directoryが一致しているとは限りません。そのため、常にスクリプトの位置を示しておけばバグが少なくなります。その書き方が、
${__dirname}
です。しかし、今回は逆にバグを誘発してしましましたので削除しています。
:::

# UNデータのベクトルタイルを実際に作成する
次に実際のLinuxPCのPostGISデータベースから、UNの小縮尺のベクトルタイルを作成していきます。

## modify.jsの解説
[こちら](https://github.com/unvt/produce-gsc-6/blob/main/produce-gsc-small/modify.js)にあるmodify.jsを使用しますが、まずは必要な部分を解説します。

1行目に以下の部分がありますが、このモジュールは実際には使用されていません。
const geojsonArea = require('@mapbox/geojson-area')

また、以下の関数flapも使用されていません。

```javascript:modify.js
const flap = (f, defaultZ) => {
  switch (f.geometry.type) {
    case 'MultiPolygon':
    case 'Polygon':
      let mz = Math.floor(
        19 - Math.log2(geojsonArea.geometry(f.geometry)) / 2
      )
      if (mz > 15) { mz = 15 }
      if (mz < 6) { mz = 6 }
      return mz
    default:
      return defaultZ ? defaultZ : 10
  }
}
```

その他の部分は大体[こちらの記事の解説](https://qiita.com/k96mz/items/fbbc96b183862e441337#un-clear-map%E3%81%AE%E3%83%99%E3%82%AF%E3%83%88%E3%83%AB%E3%82%BF%E3%82%A4%E3%83%AB%E3%82%92%E4%BD%9C%E6%88%90%E3%81%99%E3%82%8B)と同様です。

本番PostGIS用のdefault.hjsonで使用されているテーブル名は全て、本番PostGISのmodify.jsに存在することを確認しました。

LinuxPCで以下のコマンドを実施します。
```
node produce-gsc-small/index_un-s_pmtiles.js
```

作成したPMTilesファイルは以下です。

pmtiles形式のベクトルタイル
https\://k96mz.github.io/20241010produce-gsc-small/produce-gsc-small/un-s-tile/0-0-0.pmtiles

以下の通り地図が表示されました。

![スクリーンショット 2024-10-15 18.27.42.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3684675/ef4f0fd4-195b-8b5f-8626-a8e88ef43696.png)

index_un-s.jsを含むプログラムが正常に機能することが分かりました。

mbtiles形式のベクトルタイルも同様に作成しました。

次にindex_un-s_pbf.jsを使用して、pbf形式のベクトルタイルを作成します。mbtilesのズームレベル５までのファイル容量は20MBと大きかったのでズームレベル3まで作成することにします。

ローカルにあるindex_un-s_pbf.jsファイルをVMWare Horizon Clientのデスクトップに移します。次に、ベクトルタイル作成用サーバにPuttyで入って、その後対象ファイルを移動するフォルダの書き込み権限を変更すると、エラーなしでファイル移動が可能です。

default.hjsonのファイルがun用データのものになっていることを確認して、以下のコマンドを実行します。
```
sudo node produce-gsc-small/index_un-s_pbf.js
```

作成されたpbfFilesフォルダをローカルに移動させて、Githubにアップロードします。

pbf形式のベクトルタイル
https\://k96mz.github.io/20241010produce-gsc-small/pbfFiles/{z}/{x}/{y}.pbf

Maputnikを使用して、modify.jsを見ながらスタイルファイルを作成していきます。


![スクリーンショット 2024-12-05 14.28.08.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3684675/c52c6044-5752-80fb-8f67-3f1e05884709.png)

スタイルファイルを使用してMaplibreで表示した地図が以下です。

![スクリーンショット 2024-12-05 14.41.26.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3684675/fa475c17-9191-01f5-73e2-a38974f76fb6.png)


# ズームレベル0~5のオープンストリートマップデータのベクトルタイルを作成する

次にズームレベル0~5のオープンストリートマップデータのベクトルタイルを作成するコードを解説します。

「index_osm-s.js」と「index_osm-s-compact.js」の２つのコードがありますが、ほぼ同じで「index_osm-s-compact.js」の方が若干情報量が多いのでこちらを解説します。コードの大部分は「index_un-s.js」と同一なので省略して、以下のみ解説します。

```javascript: index_osm-s-compact.js
if (table == 'roads_major_0408_l') {
  sql = `
    DECLARE cur CURSOR FOR 
    SELECT ST_AsGeoJSON(ST_Simplify(ST_LineMerge(
      ST_Collect(ARRAY(SELECT geom FROM ${schema}.${table} WHERE z_order=1 OR z_order=3))
    ),0.001, true))
  `;
}
```

## コード解説
### ST_Simplify
ジオメトリを単純化しています。0.001 の単位でジオメトリを簡略化し、true は有効なジオメトリに変換するオプションです。解説は[こちら](https://postgis.net/docs/ST_Simplify.html)にあります。

ST_Simplify(geometry, tolerance, preserveTopology)
のように使用します。
・geometry
簡略化する対象のジオメトリ（例：ポリゴン、ラインストリングなど）。

・tolerance
簡略化の度合いを指定する数値。この値が大きいほど、ジオメトリはより大まかに簡略化されます。小さな値を指定すれば、精度を保ちつつ簡略化しますが、大きな値を指定すると、細かい部分が削除され、大雑把な形になります。

・preserveTopology
トポロジー（空間的な関係）を維持するかどうかを決めるオプションです。true を指定すると、簡略化の過程でポリゴンが交差したり、自己交差するなどの無効な形状が発生するのを防ぎますが、その分計算に時間がかかります。false（デフォルト）では、この制約を無視して計算が高速になります。

### ST_LineMerge
線のジオメトリをマージ（合併）します。複数の線分があれば、それらを一つの線に統合します。解説は[こちら](https://postgis.net/docs/ST_LineMerge.html)にあります。

主に MULTILINESTRING 形式のジオメトリを LINESTRING に変換するために使われます。MULTILINESTRING とは、複数の線分が一つにグループ化されたジオメトリで、これを連続する一つの LINESTRING にできる場合（例えば、線分が互いに接しているとき）、ST_LineMerge を使用してそれを実行します。
もし線分が連続していない場合や、結合できない場合は、そのまま MULTILINESTRING のまま返されます。下図で青線が、茶色線に変換されるイメージです。

![スクリーンショット 2024-10-16 11.33.29.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3684675/dc639089-d96d-6306-c8ca-b623dd178476.png)

使用例
```
SELECT ST_AsText(ST_LineMerge(
  ST_GeomFromText('MULTILINESTRING((0 0, 1 1), (1 1, 2 2))')
));
```
この例では、2つの連続したラインストリング ((0 0, 1 1), (1 1, 2 2)) が結合され、一つの LINESTRING(0 0, 1 1, 2 2) が返されます。



### ST_Collect
複数のジオメトリを一つのジオメトリコレクションにまとめるために使用されます。ジオメトリコレクションとは、複数の異なるジオメトリ（ポイント、ライン、ポリゴンなど）を一つのオブジェクトに格納するデータ型です。
ARRAY(SELECT geom ...) 部分で geom（ジオメトリ）の配列を取得し、それらを ST_Collect でまとめています。
解説は[こちら](https://postgis.net/docs/ST_Collect.html)にあります。つまり、配列だったものが、一つの値になるということだと思います。


・SELECT geom FROM \\${schema}.${table} WHERE z_order=1 OR z_order=3
schema と table に基づき、z_order が 1 または 3 であるジオメトリを選択しています。z_order はおそらく道路ジオメトリの優先順位（交差順？）を示す属性で、ここでは特定の条件に合うデータを取得しています。

このコードでやっていることをまとめると、道路データについて、ST_Collectでジオメトリをまとめて、ST_LineMergeで一つの線に統合して、ST_Simplifyで簡略化しています。


chatGPTに聞いたところ、エラーがあるとのことでした。原因分かればまた更新します。

•	ARRAY(SELECT geom FROM ...) は PostgreSQL の ARRAY 形式として不正 です。
•	ST_Collect() は複数のジオメトリを受け取るため、ARRAY は不要。


## その他
default.jsonについては、オープンストリートマップ用のPostGISの設定に修正しました。
modify-compact.jsについては、使用されていないモジュール「@mapbox/geojson-area」とそれに関連する部分をコメントアウトしました。

### 実行
実行してみましたが、以下のようなエラーが出ました。null geometry という問題が発生しています。これは、GeoJSON のオブジェクト内に空のまたは無効な geometry が含まれていることを意味しています。

![スクリーンショット 2024-10-16 14.12.03.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3684675/071c08dc-586b-ab8a-6d03-40e25fa9119b.png)

null や無効な geometry を含むデータが処理されないように、事前にフィルタリングして、処理の対象から外す必要があるのかもしれません。

一応作成したPMTilesファイルを表示しようとしてみましたが、データが壊れているのか表示出来ませんでした。default.hjsonの設定ファイルを見ると、道路データのみであり、ズームレベル５までのみの表示なので、うまくいかなくても実質上はあまり問題ないかもしれません。

# まとめ
実際の運用で使用されているproduce-gsc-smallというレポジトリの中身について解説しました。UNデータが入っているun_baseデータベースからズームレベル0~5のベクトルタイルを作成するコードを実行し、MBTiles形式、PMTiles形式、PBF形式のベクトルタイルを得ました。PMTilesビューワーで確認し、きちんと作成されていることが分かりました。
他方で、オープンストリートマップデータが入っているosm_baseデータベースからズームレベル0~5のベクトルタイルを表示するコードを試してみましたが、エラーが発生しました。解決方法等分かれば、また追記したいと思います。

# Reference

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

https://github.com/unvt/produce-gsc-6/tree/main/produce-gsc-small

https://www.npmjs.com/package/better-queue

https://pmtiles.io/