PostGISのTopology機能に入門してみました

筆者は新しい技術を普遍的に身につけたいというよりも、ほぼ自分がやりたいことを実現するために必要な技術にしか興味がないようなタイプです。
自分がやりたいことに必要ならばまだだれも使ってないような新しい技術でも平気で手を出しますが、やりたいことに関連しない技術には、世間でどれだけ流行っていてとしてもあまり関心がありません。
特に、古い技術でも十分に機能するのに、新しい技術でなければならないという風潮には、常に疑問を感じています。

最近は興味が古地図などの一般的とはいいがたい位置情報中心になってしまったこともあり、空間データベースについては、GEOMETRYやGEOGRAPHY型の実装レベルで理解がほとんど止まっています。
その後登場した新しいデータ型についてはほとんど興味も知識もありませんでした。

ところが最近、MySQLに不足しているGIS機能について質問されたことがありました。
この時は、前述のとおり最新のGISデータベースの機能向上について疎かったため、明確な回答ができませんでした。
これはまずいと思い、PostGISの知らない機能について、もう一度入門してみようと思ったのが、本記事の目的です。
興味のないことには興味を払いづらくなりますが、しかし知ることによって、新しくやってみたいと思うことが増えるかもしれないとも感じています。

調べてみると最新のPostGISには、多次元対応やpostgis_raster、postgis_topology、postgis_sfcgal、address standardizerなど、多くの追加機能が存在していました。
その中には名前を見ただけではどんな機能か想像できないものもありましたが、とりあえず本記事ではTopologyについて軽く調べてみることにしました[^1]。

### トポロジーとジオメトリの違い

* ジオメトリ型
  * 各ポリゴンは独立して存在
  * 境界線が重複して保存される
  * 隣接する形状間で微小なギャップやオーバーラップが発生する可能性がある
* トポロジー型 
  * 境界線は一度だけ保存され、複数の形状で共有される
  * エッジ、ノード、フェイスの関係が明示的に保持される
  * 共有される境界の整合性が自動的に保証される
  * データの更新時に、関連する全ての形状の整合性が維持される

というもののようで、おおむね[GeoJSON](https://ja.wikipedia.org/wiki/GeoJSON)と[TopoJSON](https://ja.wikipedia.org/wiki/GeoJSON#TopoJSON)の違いと同じような感じのようです[^2]。
複数の形状で共有される境界線が整合性を保ちながら管理されるので、例えば東京と横浜が接している場合、一方を更新すると自動的にもう一方も更新されます。
このような整合性や位相関係の維持は、

* 行政区域の境界管理
* 土地区画の分割・統合
* 地図の更新作業

など、多くの場合で非常に重要になります。

### トポロジーを使ってみた

実際にトポロジーを使ってみました。
試してみた環境は、Windows 11 arm64 環境での、PostsreSQL 17.2, PostGIS 3.5, pgAdmin 4の上での結果です[^3]。

#### 1. トポロジー拡張機能の有効化
まずはPostGISとトポロジーの拡張機能を有効にします。

```sql
postgres=# CREATE EXTENSION IF NOT EXISTS postgis;
CREATE EXTENSION
postgres=# CREATE EXTENSION IF NOT EXISTS postgis_topology;
CREATE EXTENSION
```

#### 2. トポロジースキーマの作成
次に、新しいトポロジーを作成します。

```sql
postgres=# SELECT topology.DropTopology('my_topo');
ERROR:  Topology 'my_topo' does not exist
CONTEXT:  PL/pgSQL関数droptopology(character varying)の14行目 - RAISE
postgres=# SELECT topology.CreateTopology('my_topo', 4326);
 createtopology
----------------
              1
(1 row)
```
#### 3. トポロジーテーブルの作成
自治体境界を格納するテーブルを作成します。
```sql
postgres=# CREATE TABLE city_boundaries (
postgres(#     id serial PRIMARY KEY,
postgres(#     name varchar(50)
postgres(# );
CREATE TABLE
```
#### 4. トポロジーカラムの追加
トポロジー用のカラムを、作成したテーブルに追加します。
```sql
postgres=# SELECT topology.AddTopoGeometryColumn('my_topo', 'public', 'city_boundaries', 'topo_geom', 'POLYGON');
 addtopogeometrycolumn
-----------------------
                     1
(1 row)
```

#### 5. サンプルデータの挿入
東京と横浜の **簡略化した** 境界ポリゴンを作成し、それぞれデータベースに挿入します。

**東京のポリゴン**
```sql
postgres=# WITH tokyo_geom AS (
postgres(#     SELECT ST_GeomFromText('POLYGON((
postgres'#         139.7 35.7,
postgres'#         139.9 35.7,
postgres'#         139.9 35.6,
postgres'#         139.7 35.6,
postgres'#         139.7 35.7
postgres'#     ))', 4326) AS geom
postgres(# )
postgres-# INSERT INTO city_boundaries (name, topo_geom)
postgres-# SELECT
postgres-#     '東京',
postgres-#     topology.toTopoGeom(
postgres(#         geom,
postgres(#         'my_topo',
postgres(#         1,
postgres(#         0.0001
postgres(#     )
postgres-# FROM tokyo_geom;
INSERT 0 1
```

**横浜のポリゴン**
```sql
postgres=# WITH yokohama_geom AS (
postgres(#     SELECT ST_GeomFromText('POLYGON((
postgres'#         139.7 35.6,
postgres'#         139.9 35.6,
postgres'#         139.9 35.4,
postgres'#         139.7 35.4,
postgres'#         139.7 35.6
postgres'#     ))', 4326) AS geom
postgres(# )
postgres-# INSERT INTO city_boundaries (name, topo_geom)
postgres-# SELECT
postgres-#     '横浜',
postgres-#     topology.toTopoGeom(
postgres(#         geom,
postgres(#         'my_topo',
postgres(#         1,
postgres(#         0.0001
postgres(#     )
postgres-# FROM yokohama_geom;
INSERT 0 1
```

![いい加減ポリゴン](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/10108/abaacb30-143a-3312-15b4-32877d6b8a8b.png)
簡略化しすぎ

#### 6. トポロジー関係のテスト

まずは自治体境界間の接続を確認し、その境界長さも確認します。
それぞれの面積も計算します。

**市境界の接続確認**
接続状況がtrue、境界のlengthも計算されています。
```sql
postgres=# SELECT a.name AS city1, b.name AS city2,
postgres-#     ST_Intersects(
postgres(#         topology.Geometry(a.topo_geom),
postgres(#         topology.Geometry(b.topo_geom)
postgres(#     ) AS intersects,
postgres-#     ST_Length(
postgres(#         ST_Intersection(
postgres(#             topology.Geometry(a.topo_geom),
postgres(#             topology.Geometry(b.topo_geom)
postgres(#         )
postgres(#     ) AS boundary_length
postgres-# FROM city_boundaries a, city_boundaries b
postgres-# WHERE a.id < b.id;
 city1 | city2 | intersects |   boundary_length
-------+-------+------------+---------------------
 東京  | 横浜  | t          | 0.20000000000001705
(1 row)
```

**各市の面積計算（平方度単位）**
`topology.Geometry`という関数を通じて、ジオメトリの関数も普通に使えるようです。
```sql
postgres=# SELECT name,
postgres-#     ST_Area(topology.Geometry(topo_geom)) AS area
postgres-# FROM city_boundaries;
 name |        area
------+---------------------
 東京 | 0.02000000000000199
 横浜 | 0.04000000000000398
(2 rows)
```

**実際のトポロジ情報を見てみる**
トポロジ特有のデータ概念（エッジ、ノード、フェース）がどのように保存されているか見てみましょう。
```sql
postgres=# SELECT edge_id, start_node, end_node, next_left_edge, abs_next_left_edge, next_right_edge, abs_next_right_edge, left_face, right_face, ST_AsText(geom) As geom FROM my_topo.edge_data;
 edge_id | start_node | end_node | next_left_edge | abs_next_left_edge | next_right_edge | abs_next_right_edge | left_face | right_face |             geom
---------+------------+----------+----------------+--------------------+-----------------+---------------------+-----------+------------+---------------------------------------------------------
       3 |          3 |        1 |               1|                  1 |              -2 |                   2 |         0 |          1 | LINESTRING(139.7 35.6,139.7 35.7)
       1 |          1 |        2 |               4|                  4 |              -3 |                   3 |         0 |          1 | LINESTRING(139.7 35.7,139.9 35.7,139.9 35.6)
       2 |          2 |        3 |              -4|                  4 |              -1 |                   1 |         2 |          1 | LINESTRING(139.9 35.6,139.7 35.6)
       4 |          2 |        3 |               3|                  3 |               2 |                   2 |         0 |          2 | LINESTRING(139.9 35.6,139.9 35.4,139.7 35.4,139.7 35.6)
(4 rows)

postgres=# SELECT node_id, containing_face, ST_AsText(geom) As geom FROM my_topo.node;
 node_id | containing_face |       geom
---------+-----------------+-------------------
       1 |                 | POINT(139.7 35.7)
       2 |                 | POINT(139.9 35.6)
       3 |                 | POINT(139.7 35.6)
(3 rows)

postgres=# SELECT face_id, ST_AsText(mbr) AS mbr FROM my_topo.face;
 face_id |                                mbr
---------+-------------------------------------------------------------------
       0 |
       1 | POLYGON((139.7 35.6,139.7 35.7,139.9 35.7,139.9 35.6,139.7 35.6))
       2 | POLYGON((139.7 35.4,139.7 35.6,139.9 35.6,139.9 35.4,139.7 35.4))
(3 rows)
```
データはエッジとノードの組み合わせ、互いの位相関係（start_node、end_node、next_left_edge、など）で管理されていて、フェースは存在とそのMBR（最小外接矩形）、そしてエッジとの位相関係（left_face、right_face）で管理されているようですね。
face_id: 0というのは、外部に広がる広大な無限平面のようです。

### とりあえずデータ構造確認まで

今回の記事では、とりあえずトポロジのデータ構造まで確認してみました。
なにやら、トポロジを使うと位相関係を維持したまま形状を変更したりもできるという情報もあったりするのですが、というか実際それができるのがトポロジの利点だと思うのですが、それをテストするためのテストケースなどはまだちょっとうまく用意できていないので、次の記事に回したいと思います。

[^1]: 他の機能についても、記事を分けてまた調べてみたいと思っています。
[^2]: この辺、調査に生成AIの力も借りて調査時間を大幅に減らせたのですが、一方で生成AIにありがちなハルシネーションに悩まされたのも事実で、「TopoJSONとPostGISのTopologyの概念は全く違うものだ！」と言い張れば聞かない生成AIもあり、ちょっと間違ってる裏付けとるのに苦労もしました。技術的な話題については、私が触った限り、[Claudeの3.5 Sonnet](https://claude.ai)に優る生成AIはないような気がします。
[^3]: x64向けのインストーラしかなかったんですが、普通にarm Windowsで動いてますね