🧙

空間インデックス(Quadkey・S2・空間ID)の4進数表現部分を16進数に可逆圧縮可能なnpmパッケージを作成・公開してみた

に公開

はじめに

きっかけは、地理空間情報課ラボのアイデア募集「Q5 建物へのIDの付番」への取り組みの中で、以下の点に気づいたことでした。

  1. Quadkeyは他の空間インデックス(GeohashやGoogleのOpen Location Code (Plus Codes))に比べて、同じレベルの範囲を表す際、桁数が長くなる
    (以下の例では、Geohashが9桁、Open Location Codeが11(+1)桁のところ、Quadkeyは23桁となる)
  2. 空間IDのタイルハッシュ形式(Z曲線)で高度(Floor)値がない場合は、Quadkeyの各桁を+1したものと同じになる
    (以下の例では、Quadkeyのコード値が 13300211231200101110210 のところ、空間ID (TileHash)のコード値は 24411322342311212221321 となる)

東京駅付近の緯度・経度 35.675,139.762 の各ジオコード値(リンク)

Geohash Quadkey
Open Location Code 空間ID (TileHash)

桁数の短さだけではGeohashが最も短くなるものの、空間IDについては経産省の「3次元空間情報基盤アーキテクチャ設計 報告書」の p.36-40 にXYZタイル(4分割)とGeohash(32分割)との違いがかなり詳細に記載されていて、3次元空間でのボクセル表現にあたっては、各ズームレベルで8x4、4x8の順に長方形で32分割するGeohashよりも、QuadkeyなどのXYZ形式の方が優れているように思えたので、Quadkeyや空間IDのタイルハッシュ形式でコード値を短く圧縮する方法について考えてみました。

圧縮方式の検討

圧縮にあたっての要件

圧縮にあたっての要件としては以下のようなものを考えました。

  1. 圧縮後、元のコード値の復元が可能なこと (=可逆圧縮であること)
  2. 圧縮状態でも、元のコード値が備えている、前方一致検索性能を保持すること
  3. 圧縮状態でのソート時に、元のコード値と同じ順序となること

4進数から16進数への変換

Quadkeyは各桁が0-3の4進数(2ビット)、空間IDのタイルハッシュ形式(Z曲線)も高度(Floor)値が0の場合は各桁が1-4となり、各桁を-1すれば4進数となるので、圧縮方式としては4進数の各桁を先頭から2桁(=2x2ビット)ずつ取り出して16進数(4ビット)に変換する方法を考えました。
(圧縮効率面からは、他にBase32(5ビット)、Base64(6ビット)なども可能かもしれませんが、可変桁の場合の複雑さが増すと思われたため、16進数(4ビット)を選択しました。)

先ほどのズームレベル23では少し長くなるので、以下の例ではズームレベル16にまで落としています。

Quadkeyのズームレベル16の場合の変換

桁数 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
4進数 1 3 3 0 0 2 1 1 2 3 1 2 0 0 1 0
2進数 (2ビット) 01 11 11 00 00 10 01 01 10 11 01 10 00 00 01 00
桁数 1 - 2 3 - 4 5 - 6 7 - 8 9 -10 11-12 13-14 15-16
2進数 (4ビット) 0111 1100 0010 0101 1011 0110 0000 0100
16進数 7 c 2 5 b 6 0 4

ズームレベルが16で偶数の場合は、2桁ずつ取り出して変換することができるので、16進数にピッタリ変換することができますが、ズームレベル16から一つ落としたズームレベル15の場合はどうでしょう?

Quadkeyのズームレベル15の場合の変換

桁数 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
4進数 1 3 3 0 0 2 1 1 2 3 1 2 0 0 1
2進数 (2ビット) 01 11 11 00 00 10 01 01 10 11 01 10 00 00 01
桁数 1 - 2 3 - 4 5 - 6 7 - 8 9 -10 11-12 13-14 15-
2進数 (4ビット) 0111 1100 0010 0101 1011 0110 0000 01
16進数 7 c 2 5 b 6 0 ?

上のように最後の桁が半分(01)となってしまい、16進数に無理に変換しようと 00 を追加して 0100 とし、16進数に変換して 4 となっても、他の16進数文字と区別がつかなくなってしまいます。

4進数が奇数桁の場合の対応

解決策として、最初は 00 を追加して16進数文字に変換した後、末尾に何らかの文字(u, m< など)を追加して、それらの記号が末尾にある場合は、4進数での桁を1つ(2進数では2ビット分)戻すような方法を考えてました。(uuppermmiddle の頭文字で、< は左側に1桁戻すイメージから来ています。)
Base64で用いられるフィラー(=)とは意味合いが異なりますので、レデューサー(Reducer)のような命名を考えていました。

上の例では、00 を追加して 0100 で16進数で 4 となり、レデューサーに u を利用の場合は末尾が 4u となります。

既存事例の調査

上記の辺りで少し行き詰まり、これまでで同じような課題に取り組んだ事例がないか、おもむろにChatGPTのDeepResearchで調べてみたところ、以下の記事・論文を見つけました。

https://gis.stackexchange.com/questions/236508/understanding-the-s2-library-geometry-on-the-sphere-cells-and-hilbert-curve-f

https://osm.codes/_foundations/art1.pdf

1つ目のGIS StackExchangeの Peter Krauss さんの回答では、S2 Geometryのキーの一部がbase4(4進数)で表現されていること、また、16進数への圧縮にあたって、奇数桁の場合には、半桁(half digit)の部分にbase16(16進数)を拡張した J, K, L, M を当てる方法が紹介されていました。

Suggestion for base16 encoding

About convert "Face/Face_pos" to hexadecimal, is possible for example convert the base4 10002200 to hexa 40a0, but base4 with more one digit will result in entirely different (or invalid) hexadecimal; for example 100022003 results in 102801... The "more one digit" ocurrs when you compare for example level 18 keys with level 19 keys.

To avoid this problem, an extra digit must be added by a extend base16 algorithm.

function base4_to_base16(str) {
  const tr = {"0":"00","1":"01","2":"10","3":"11"}
  let strBin=''
  for(let i of str.split('')) strBin += tr[i]
  return BigInt('0b'+strBin).toString(16)
} 

function base4_to_base16h(str) {
  const tr = {"0":"J","1":"K","2":"L","3":"M"}
  let len = str.length
  if (len % 2 == 0)
    return base4_to_base16(str);
  let h = base4_to_base16( str.slice(0,-1) )  // cut last
  return h+tr[ str.slice(-1) ]
} 

var key = S2.latLngToKey(i.lat, i.lon, level);
[face,face_pos] = key.split('/');
keyHex = face + base4_to_base16h(face_pos)

2つ目の同じ Peter Krauss さんの論文では、4進数=>16進数への変換だけでなく、より汎用的に、2進数や8進数からの変換にも対応した Base16h が定義されていて、4進数からの変換の場合の拡張文字としては H, M, R, V を利用しているようでした。
論文内ではGitHubリポジトリも紹介されていて、JavaScript(ESM形式)のAPIとPostgreSQLのストアド(PL/pgSQL)関数の実装もあるようでした。

https://github.com/ppKrauss/SizedBigInt

圧縮状態でのソート順の問題

既存事例・実装があるので、それをそのまま利用するnpmパッケージを作成しても良いようにも思いましたが、要件の3つ目で上げていた「圧縮状態でのソート時に、元のコード値と同じ順序となること」を満たすかを確認したところ、拡張文字がASCII・Unicodeコード上で16進数に利用される文字コード([0-9a-f])よりも大きくなることにより、ソート順が元のコード値と異なってしまうことがわかりました。

JavaScriptでの例:

// 元の4進数コード値
const quadKeys = [
  '13300211231200',   // ズームレベル14
  '133002112312001',  // ズームレベル15
  '1330021123120010', // ズームレベル16
];
console.log(quadKeys.sort());
// => ['13300211231200', '133002112312001', '1330021123120010']

// 圧縮後の16進数コード値
const hexKeys = [
  '7cb5260',  // ズームレベル14
  '7cb5260K', // ズームレベル15
  '7cb52604', // ズームレベル16
];
console.log(hexKeys.sort());
// => ['7cb5260', '7cb52604', '7cb5260K'] <= ズームレベル14,16,15の順になってしまう
PostgreSQLでの例:
SELECT quadkey FROM (
  VALUES
    ('13300211231200'),   -- ズームレベル14
    ('133002112312001'),  -- ズームレベル15
    ('1330021123120010')  -- ズームレベル16
) AS t(quadkey)
ORDER BY quadkey;
-- =>
--      quadkey      
-- ------------------
--  13300211231200
--  133002112312001
--  1330021123120010
-- (3 rows)
SELECT hexkey FROM (
  VALUES
    ('7cb5260'),  -- ズームレベル14
    ('7cb5260K'), -- ズームレベル15
    ('7cb52604')  -- ズームレベル16
) AS t(hexkey)
ORDER BY hexkey;
-- =>
--   hexkey  
-- ----------
--  7cb5260
--  7cb52604
--  7cb5260K <= ズームレベル14,16,15の順になってしまう
-- (3 rows)

なお、上記のソート順の問題は、拡張文字利用時だけでなく、レデューサー文字利用時にも発生します。

JavaScriptでの例:

// 圧縮後の16進数コード値
const hexKeys = [
  '7cb5260',    // ズームレベル14
  '7cb52604u',  // ズームレベル15
  '7cb52604',   // ズームレベル16
];
console.log(hexKeys.sort());
// => ['7cb5260', '7cb52604', '7cb52604u'] <= ズームレベル14,16,15の順になってしまう
PostgreSQLでの例:
SELECT hexkey FROM (
  VALUES
    ('7cb5260'),    -- ズームレベル14
    ('7cb52604u'),  -- ズームレベル15
    ('7cb52604')    -- ズームレベル16
) AS t(hexkey)
ORDER BY hexkey;
-- =>
--   hexkey   
-- -----------
--  7cb5260
--  7cb52604
--  7cb52604u <= ズームレベル14,16,15の順になってしまう
-- (3 rows)
インジケータ記号(#)を利用したソート順の問題の解消

圧縮後もソート順を保持するには、次の桁が16進数文字最小の 0 から始まった場合も、その前に並ぶように、ASCII・Unicodeコード上で 0 より前の文字を利用する必要がありそうです。

ASCIIコードの印字可能範囲、もしくはUnicodeコードの U+0020 - U+007F 範囲は、下表のようになります。

https://ja.wikipedia.org/wiki/ASCII#ASCII印字可能文字

https://ja.wikipedia.org/wiki/Unicode一覧_0000-0FFF

0 1 2 3 4 5 6 7 8 9 A B C D E F
20 SP ! " # $ % & ' ( ) * + , - . /
30 0 1 2 3 4 5 6 7 8 9 : ; < = > ?
40 @ A B C D E F G H I J K L M N O
50 P Q R S T U V W X Y Z [ \ ] ^ _
60 ` a b c d e f g h i j k l m n o
70 p q r s t u v w x y z { \ } ~ DEL

ASCII・Unicodeコード上で0より前の文字は限られていて、 !, " , #, $, %, &, ', (, ), *, +, ,, -, ., / の15文字(記号)です。

ケースとしてはあまりないかもしれませんが、ファイル名やフォルダ名としても利用することを想定した場合、Windowsファイルシステムの予約文字 <, >, :, ", /, \, |, ?, * に含まれる文字は外した方が良さそうです。

https://learn.microsoft.com/ja-jp/windows/win32/fileio/naming-a-file

また、正規表現で利用される文字 (, ), *, +, ., ?, {, }, [, ], ^, $, | に含まれる文字も外したほうが良さそうです。

各文字(記号)の利用可能性についてまとめたところ、以下の表のようになりました。

ASCII・Unicodeコード 文字(記号) 利用可能性 備考
0x21 ! o
0x22 " x Windowsファイルシステム予約文字
0x23 # o
0x24 $ x 正規表現 (行の末尾)
0x25 % o
0x26 & o
0x27 ' o
0x28 ( x 正規表現 (グループ化)
0x29 ) x 正規表現 (グループ化)
0x2A * x Windowsファイルシステム予約文字
正規表現 (直前の要素の0回以上の繰り返し)
0x2B + 正規表現 (直前の要素の1回以上の繰り返し)
後述の空間IDの高度(Floor)成分の符号として利用
0x2C , o
0x2D - 後述の空間IDの高度(Floor)成分の符号として利用
0x2E . x 正規表現 (任意の1文字)
0x2F / x Windowsファイルシステム予約文字

利用可能な文字として !, #, %, &, ', , の6文字が残りましたが、これらから4文字を先の拡張文字 J, K, L, M の代わりに当てるのは難しいように思えましたので、意味合い的にURLのページ内(アンカー)リンク指定やMarkdownの見出しにも利用される # を半桁のインジケータとして利用し、直後には 0, 1, 2, 3 の4進数文字をそのまま当てることにしました。
(他に、桁と桁の中間の意味合いから、10進数で小数部との区切りに利用される . の利用も検討しましたが、少数部が 0.0 から 0.f まで上がった後に 1.0 となってしまう(=小数部が実質16分割となる)ことから見送りました。)

Quadkeyの場合、並びはZ階数曲線となることから、ズームレベル偶数時は16分割([0-9a-f])となるところ、ズームレベル奇数時は4分割で # の後にZ曲線順の4進数文字(0, 1, 2, 3)を当てはめれば良いので、直感的にも分かりやすいように思えました。


画像引用元: https://learn.microsoft.com/en-us/bingmaps/articles/bing-maps-tile-system?redirectedfrom=MSDN#tile-coordinates-and-quadkeys

ソート順の問題も、以下の通り、解消していることを確認できました。

JavaScriptでの例:

// 圧縮後の16進数コード値
const hexKeys = [
  '7cb5260',    // ズームレベル14
  '7cb5260#1',  // ズームレベル15
  '7cb52604',   // ズームレベル16
];
console.log(hexKeys.sort());
// => ['7cb5260', '7cb5260#1', '7cb52604'] <= ズームレベル順が維持される
PostgreSQLでの例:
SELECT hexkey FROM (
  VALUES
    ('7cb5260'),    -- ズームレベル14
    ('7cb5260#1'),  -- ズームレベル15
    ('7cb52604')    -- ズームレベル16
) AS t(hexkey)
ORDER BY hexkey;
-- =>
--   hexkey   
-- -----------
--  7cb5260
--  7cb5260#1  <= ズームレベル順が維持される
--  7cb52604
-- (3 rows)

空間IDのタイルハッシュ形式(Z曲線)で高度(Floor)値がある場合の対応

次に、空間IDのタイルハッシュ形式(Z曲線)で高度(Floor)値がある場合の対応を考えます。

東京駅付近の緯度・経度 35.675,139.762、高度 10000 m、ズームレベル16の空間IDは以下のようになります。

  • ZFXY形式: 16/19/58210/25808
  • タイルハッシュ形式(Z曲線): 2441132234271165

タイルハッシュ形式(Z曲線)では、以下の公式SDKのREADME.mdに記載の通り、 北西下、北東下、南西下、南東下、上などの8方向 となっていて、下段の後に上段が続く形となっています。

https://github.com/spatial-id/javascript-sdk?tab=readme-ov-file#空間idとその表現形式

  1. タイルハッシュ形式
    * Z曲線ヒルベルト曲線で利用できます。
    * 空間IDは再帰的に計算でき、ズームレベル1から指定のズームレベルまで、各ズームレベルでの相対的な位置(北西下、北東下、南西下、南東下、上などの8方向)を1文字ずつ追加して、グローバルな位置を特定します。

以下のサイトにある、Z曲線の3次元の場合の画像イメージがとても分かりやすいです。

https://note.g2-studios.net/n/nfbc1d878fc78


画像引用元: https://note.g2-studios.net/n/nfbc1d878fc78

上段にある 4, 5, 6, 7 は、8進数(3ビット)の最上位ビットとなるため、以下の手順で各桁をYX成分とF(高度)成分に分け、それぞれを16進数に変換した後、F(高度)成分を +/- の符号に続けて末尾に追加する形としました。

手順 内容 データ
1 元データ 2441132234271165
2 各桁を-1して8進数に変換 1330021123160054
3 8進数を2進数に変換(桁数が元データ桁数
の3倍に満たない場合は先頭を0埋め)
001 011 011 000 000 010 001 001 010 011 001 110 000 000 101 100
4 各桁内の3ビットをYX成分(2-3ビット目)とF(高度)成分(1ビット目)に分割 YX成分: 01 11 11 00 00 10 01 01 10 11 01 10 00 00 01 00
F成分: 0 0 0 0 0 0 0 0 0 0 0 1 0 0 1 1
5 YX成分は4進数から16進数に変換、
F成分は2進数から16進数に変換
YX成分: 0111 1100 0010 0101 1011 0110 0000 0100 => 7cb52604
F成分: 0000000000010011 => 13
6 YX成分末尾にF成分を+/-の符号に続けて追加 7cb52604+13

F成分の 13 は、ZFXY形式の 16/19/58210/25808 の2節目のF値 19 の16進数表現に相当するため、正しく変換できていることが分かります。

16進数部分先頭へのxの追加

上記で圧縮時に追加で利用する記号 #, +, - が決まりましたが、最後に圧縮前後のコード値の区別を容易にするため、圧縮後の16進数部分の先頭に16進数の略字として良く用いられる x (Hexadecimalのx)を追加することにしました。

npmパッケージの作成と公開

圧縮方式についての方針が決まったので、以下のZenn記事や地理情報関連のGitHubリポジトリを参考に、pnpmとViteのライブラリモードでnpmパッケージの作成を行いました。

参考にしたZenn記事

pnpm create vite@latest からのViteライブラリモードのテンプレート選択手順や、 tsconfig.json の修正、buildスクリプトの修正など、基礎的なViteライブラリモードでのパッケージ作成手順は以下のZenn記事を参考にしました。

https://zenn.dev/drumath2237/articles/616ea9a1bb5fe7
https://zenn.dev/s_takashi/articles/20ecebd0a42010

参考にした地理情報関連のGitHubリポジトリ

npmパッケージのAPI設計やライブラリ・デモ用のVite設定(vite*.config.ts)、Vitestでのテストコード作成は、以下のGitHubリポジトリを参考にしました。

https://github.com/glassonion1/quadkey-tilemath
https://github.com/mug-jp/maplibre-gl-temporal-control
https://github.com/watergis/maplibre-gl-export
https://github.com/w8r/Leaflet.Path.Transform

作成したGitHubリポジトリとnpmパッケージ

一からのnpmパッケージ作成は初めてのトライアルでしたが、上記のZenn記事やGitHubリポジトリを参考にしつつ、ChatGPTやGitHub Copilotの助けも借りて、何とかリリースまでたどり着くことができました。

作成したGitHubリポジトリとnpmパッケージ、デモイメージ・URLは以下となりますので、興味のある方は是非御覧ください。

https://github.com/sanak/quad-hexer

https://www.npmjs.com/package/quad-hexer


デモURL: https://sanak.github.io/quad-hexer/

おわりに

以上、ざっととなりますが、空間インデックス(Quadkey・S2・空間ID)の4進数表現部分を16進数に可逆圧縮するnpmパッケージの作成についてまとめました。

GitHubリポジトリのコードの方は、正規表現でのフォーマットチェックと、文字列ベースの愚直な変換処理となっていて、ビット演算の導入などでパフォーマンス改善やGo・Rustなどの他言語への移植性も高まるかもしれませんが、この辺りは今後の課題としたいと思います。

Georepublic Tech Blog

Discussion