H3の六角形とGeohashの長方形、どちらを使うか決まっていなければ、先にH3 vs Geohash vs S2の比較記事を読んでください。この記事はその次のステップ、どのnpmパッケージをnpm installするかを扱います。
h3-jsとngeohashは、それぞれH3とGeohashで最もダウンロードされているJavaScriptライブラリです。KunYuのGeoHash & H3変換ツールでは両方を使っています。バンドルサイズは22倍、エンコード速度は4〜6倍の差があり、本番で踏む地雷もまったく違います。実測値と、リリース後に初めて気づいた問題をまとめました。
バンドルサイズ:86 KB vs 3.8 KB
node_modulesからESモジュールのエントリーポイントを直接gzipして計測しました。
| 指標 | h3-js v4.4.0 | ngeohash v0.6.3 |
|---|---|---|
| ESモジュール(gzip後) | 86 KB | 3.8 KB |
| パッケージ総サイズ | 6.5 MB | 52 KB |
| ランタイム依存 | 0 | 0 |
| 実装方式 | C → Emscripten | Pure JavaScript |
22倍の差は実装方式そのものです。h3-jsはUberのCライブラリをEmscriptenでコンパイルしたもので、正二十面体投影、六角形グリッドの走査、ポリゴンクリッピングが全部1つのJSファイルに焼き込まれています。tree-shakingは効きません。{ latLngToCell }だけをインポートしても464 KBのフルバンドルが出荷されます。一方、ngeohashは手書きの6ファイル構成で、ライブラリ全体がh3-jsの型定義ファイルより小さいです。
gzip後86 KBはReact DOMとほぼ同じサイズです。サーバーサイドなら問題になりませんが、フロントエンドでたった1回のエンコードのためにReact DOMをもう1つ出荷するのは割に合いません。
エンコード速度:ngeohashが4〜6倍速い
Node.js v24、1,000回のウォームアップ後に各操作100,000回のイテレーションで計測:
| 操作 | h3-js | ngeohash | 差 |
|---|---|---|---|
| エンコード(lat/lng → セル) | 850K ops/sec | 4.86M ops/sec | ngeohash 5.7倍 |
| デコード(セル → lat/lng) | 1.73M ops/sec | 7.58M ops/sec | ngeohash 4.4倍 |
| 隣接セル取得 | 614K ops/sec | 465K ops/sec | h3-js 1.3倍 |
| 境界 / BBox | 550K ops/sec | 9.29M ops/sec | ngeohash 17倍 |
ベンチマークスクリプト(.mjsとして保存し、nodeで実行):
import { latLngToCell, cellToLatLng, gridDisk, cellToBoundary } from "h3-js";
import ngeohash from "ngeohash";
const ITERATIONS = 100_000;
function bench(name, fn) {
for (let i = 0; i < 1000; i++) fn(); // warmup
const start = performance.now();
for (let i = 0; i < ITERATIONS; i++) fn();
const ms = performance.now() - start;
const opsPerSec = Math.round((ITERATIONS / ms) * 1000);
console.log(`${name}: ${opsPerSec.toLocaleString()} ops/sec`);
}
const h3Cell = latLngToCell(37.7749, -122.4194, 9);
const hash = ngeohash.encode(37.7749, -122.4194, 6);
bench("h3-js encode", () => latLngToCell(37.7749, -122.4194, 9));
bench("ngeohash encode", () => ngeohash.encode(37.7749, -122.4194, 6));
bench("h3-js decode", () => cellToLatLng(h3Cell));
bench("ngeohash decode", () => ngeohash.decode(hash));
bench("h3-js neighbors", () => gridDisk(h3Cell, 1));
bench("ngeohash neighbors", () => ngeohash.neighbors(hash));
bench("h3-js boundary", () => cellToBoundary(h3Cell));
bench("ngeohash bbox", () => ngeohash.decode_bbox(hash));
単純なエンコード/デコードではngeohashが圧倒します。ピュアJSのビットインターリーブ操作はV8 JITの得意分野で、FFIオーバーヘッドもゼロ。h3-jsは呼び出しのたびに引数をEmscriptenヒープにコピーし、C関数を実行し、結果をJavaScript側にコピーバックするマーシャリングコストがかかります。
隣接セルの検索ではh3-jsが逆転します。gridDiskは正二十面体グリッドをタイトなCループで走査するため、コンパイル済みアルゴリズムの効率がシリアライゼーションコストを上回ります。
境界の行は厳密には公平な比較ではありません。cellToBoundaryは六角形の6頂点座標を計算しますが、decode_bboxは4つの数値を返すだけです。とはいえ、本番で実際に呼ぶのはこれらの関数です。
エンコード速度はライブラリ選定の決め手にならない。 数百万ポイントをリクエストごとに処理するのでなければ、h3-jsの1.2マイクロ秒/エンコードで十分です。
API比較
エンコードとデコード
import { latLngToCell, cellToLatLng } from "h3-js";
import ngeohash from "ngeohash";
// H3: resolution 9 ≈ 0.11 km²
const h3Cell = latLngToCell(37.7749, -122.4194, 9);
// → "8928308280fffff"
// Geohash: precision 6 ≈ 0.74 km²
const hash = ngeohash.encode(37.7749, -122.4194, 6);
// → "9q8yyk"
// Decode
const [lat, lng] = cellToLatLng(h3Cell);
const { latitude, longitude } = ngeohash.decode(hash);
隣接セル
import { gridDisk } from "h3-js";
// H3: center + 6 hexagonal neighbors
const h3Neighbors = gridDisk(h3Cell, 1);
// Geohash: 8 neighbors (N, NE, E, SE, S, SW, W, NW)
const ghNeighbors = ngeohash.neighbors(hash);
セル境界
import { cellToBoundary } from "h3-js";
// H3: 6 [lat, lng] vertices
const h3Boundary = cellToBoundary(h3Cell);
// Geohash: bounding box
const bbox = ngeohash.decode_bbox(hash);
// → [minLat, minLon, maxLat, maxLon]
decode_bboxの戻り値は[minLat, minLon, maxLat, maxLon]、つまり南・西・北・東の順です。GeoJSONの[west, south, east, north]とも、LeafletのLatLngBoundsが期待する順序とも違います。間違えると地球の反対側にバウンディングボックスが出現し、エラーは一切出ません。必ず明示的に分割代入してください:
const [minLat, minLon, maxLat, maxLon] = ngeohash.decode_bbox(hash);
h3-jsはTypeScript型定義を同梱しています。ngeohashは@types/ngeohashを別途インストールする必要があります。
本番環境での落とし穴
H3セルIDがJSONシリアライゼーションで静かに切り捨てられる
一番痛い目に遭った問題がこれです。H3のセルIDは64ビット整数で、JavaScriptの安全整数範囲(2^53 − 1)を超えるため、h3-jsは16進数文字列で返します:"8928308280fffff"。
罠はAPI層にあります。バックエンドのどこかでH3 IDを文字列ではなく数値としてJSONに書き出すと、JSON.parseが値を静かに切り捨てます。有効だが全く別の数値になり、エラーは出ず、クエリは空の結果を返す。KunYuの開発中にサービス間でデータが合わない原因を丸一日追って、最終的にシリアライゼーションだったと判明しました。
修正は単純ですが徹底が必要です。データベースではstring型カラム、APIスキーマではstringフィールド、TypeScriptインターフェースではstring型。H3 IDは全レイヤーでstringとして扱う。例外なし。
ngeohashの整数エンコードは52ビットで上限
encode_intとdecode_intはBase32文字列の代わりに整数を返しますが、上限は52ビット(IEEE 754 float64の安全整数範囲)です。bitDepthを52より大きく渡しても精度が静かに失われるだけで、エラーは出ません。
Redisも内部的に52ビットのGeohash整数を使うため、Redis連携では問題になりません。ただしPythonやJavaライブラリから64ビットGeohash整数を使うロジックを移植すると値が合いません。末尾の数ビットだけが違うため結果が「近い値」に見え、デバッグが厄介です。
ngeohashと他のGeohashライブラリが一致しないことがある
同じ座標でも、Geohash実装によって結果が異なることがあります。原因は浮動小数点の丸め処理。座標がセル境界のちょうど上にある場合、ライブラリによって隣のセルに丸められます。
KunYuのGeohashツールを作っているときにこの問題に当たりました。フロントエンドのngeohash出力とバックエンドのPython geohashが時々一致しない。頻度は低いですが、実際に起きます。GPS座標(精度約3メートル)を数百メートルのセルにエンコードする用途なら実害はほぼありません。ただしNode.jsとPython/Redisが完全に一致する必要がある場合は、既知のセル境界上の座標で統合テストを書いてください。RedisのGEOHASHコマンドがクロスシステム一貫性の最も信頼できるリファレンス実装です。
h3-js v3 → v4:全関数がリネームされた
まだv3を使っているプロジェクトでは、v4でほぼ全関数名が変わります:
| v3 | v4 |
|---|---|
geoToH3 |
latLngToCell |
h3ToGeo |
cellToLatLng |
kRing |
gridDisk |
hexRing |
gridRingUnsafe |
compact |
compactCells |
v4ではエラーハンドリングも変わりました。無効な入力でnullを返す代わりにスローするようになっています。h3-js/legacyに互換レイヤーがありますが、エラー動作はv4のセマンティクスです。個人的には、レガシーラッパーに頼るよりも一括で検索・置換してしまう方がクリーンだと思います。完全なリネーム対応表はv3→v4マイグレーションガイドを参照してください。
シナリオ別の選び方
| シナリオ | 選択 | 理由(一行) |
|---|---|---|
| Redis GEO / Elasticsearch | ngeohash | Redisは内部でGeohashを使用——動作が一致する |
| フロントエンドの近接検索 | ngeohash | 3.8 KB vs 86 KB |
| 六角形ヒートマップ / Deck.gl | h3-js | Deck.glがH3HexagonLayerをネイティブサポート |
| グローバル集計(均一なセル面積) | h3-js | H3のセル面積は緯度間で3%未満の差 |
| ログで読みやすいID | ngeohash | "9q8yyk"は"8928308280fffff"より読みやすい |
| ポリゴンカバレッジ / マルチ解像度 | h3-js | polygonToCells、compactCells、親子階層 |
| サーバーサイドバッチ処理 | どちらでも | 両方とも十分に高速——ライブラリではなくインデックス体系で選ぶ |
JavaScriptでのS2は正直厳しいです。s2-geometryは8年間更新されておらず、nodes2tsは完成度は高いもののコミュニティが小さい。S2が必要ならPython/Goの公式ライブラリでマイクロサービスを立てるのが現実的です。
ブラウザでGeohashとH3のエンコードを試すなら、KunYuのGeoHash & H3変換ツールで座標を貼り付けてセルIDを確認し、境界を地図上で可視化できます。
FAQ
h3-jsはWebAssemblyを使っていますか?
いいえ。Cからのコンパイルですが、Emscriptenが出力するのはプレーンなJavaScriptで、.wasmファイルは含みません。wasmファイルの別途ロードが不要で全モダンブラウザで動く反面、バンドルサイズ(gzip後86 KB)と呼び出しオーバーヘッドはネイティブwasmより大きくなります。コミュニティでwasmビルドの議論はありますが、公式な計画は今のところありません。
ngeohashにはESMエクスポートがありますか?
ありません。パッケージはCommonJSのみです。Vite、webpackなどのバンドラーがCJS → ESM変換を自動で処理するため、バンドル済みプロジェクトならimport ngeohash from "ngeohash"で動きます。"type": "module"を指定したピュアESMのNode.jsプロジェクトではcreateRequireかコミュニティフォークが必要です。週間10万以上のnpmダウンロードがありますが、最終リリースは2020年です。
同じプロジェクトで両方のライブラリを使えますか?
はい、競合しません。バックエンドでH3を使って空間集計し、フロントエンドではngeohashで軽量なジオフェンスチェックを行い、それぞれのセルIDをAPIでやり取りするのはよくあるパターンです。2つのインデックスシステムは独立して動くため、相互変換は不要です。KunYuでもまさにこの構成で運用しています。