Supercherenkoスマレジアプリと解説メディアの個人開発

地理院地図Vectorのタイルを1枚開いて分かった、ベクタータイルのカスタマイズ

地図をWebに載せる開発者向けの話です。ベクタータイルという名前は知っているものの、中を開いたことはない、という段階の人を想定しています。国土地理院が実験提供しているタイルを1枚落として中身を読み、そこにスタイルを当てて見た目を変えるまでを、実測値つきで追いました。

前提を先に書きます。仕様は Mapbox Vector Tile Specification 2.1、スタイルは MapLibre Style Spec の version 8 です。実装の確認は MapLibre GL JS の v5 系。タイルは地理院地図Vector提供実験のもので、データは2026年4月1日時点のものです。転送量とグリフの実測は2026年9月5日に取りました。デコードは Python 3 の標準ライブラリだけで書いています。

タイルを1枚開くと、色が1つも入っていない

東京駅あたりのタイルを取ります。z14 の 14552, 6451 です。

curl -s --compressed -o tile.pbf \
  "https://cyberjapandata.gsi.go.jp/xyz/experimental_bvmap/14/14552/6451.pbf"

中身は Protocol Buffers です。仕様の言い方はこうです。

The Vector Tile format uses Google Protocol Buffers as a encoding format.

ライブラリを入れなくても、可変長整数とフィールド番号を追うだけでレイヤーの一覧までは読めます。次のスクリプトを走らせました。

import sys, gzip
from collections import Counter

buf = open(sys.argv[1], "rb").read()
if buf[:2] == b"\x1f\x8b":
    buf = gzip.decompress(buf)

def varint(b, i):
    r = s = 0
    while True:
        c = b[i]; i += 1
        r |= (c & 0x7F) << s
        if not c & 0x80:
            return r, i
        s += 7

def fields(b):
    i = 0
    while i < len(b):
        k, i = varint(b, i)
        num, wt = k >> 3, k & 7
        if wt == 0:
            v, i = varint(b, i)
        elif wt == 2:
            n, i = varint(b, i); v = b[i:i + n]; i += n
        elif wt == 5:
            v = b[i:i + 4]; i += 4
        elif wt == 1:
            v = b[i:i + 8]; i += 8
        yield num, v

GEOM = {1: "point", 2: "linestring", 3: "polygon"}

for num, layer in fields(buf):
    if num != 3:            # Tile.layers
        continue
    name = extent = version = None
    keys, geoms = [], []
    for n, v in fields(layer):
        if n == 1:    name = v.decode()
        elif n == 3:  keys.append(v.decode())
        elif n == 5:  extent = v
        elif n == 15: version = v
        elif n == 2:            # Layer.features
            for m, w in fields(v):
                if m == 3:
                    geoms.append(GEOM.get(w, w))
    print(f"{name:<12} v{version} extent={extent} {len(geoms):>5}件 "
          f"{dict(Counter(geoms))} keys={keys}")

出力から3行だけ引きます。

road         v2 extent=4096  1976件 {'linestring': 1976} keys=['orgGILvl', 'ftCode', 'rdCtg', 'lvOrder', 'rnkWidth', 'tollSect', 'medSect', 'motorway', 'Width']
label        v2 extent=4096   159件 {'point': 159} keys=['orgGILvl', 'ftCode', 'annoCtg', 'knj', 'kana', 'arrng', 'arrngAgl']
contour      v2 extent=4096   109件 {'linestring': 109} keys=['orgGILvl', 'ftCode', 'alti', 'altiFlag']

このタイル1枚に14レイヤー、地物は合わせて5,707件。全レイヤーが extent=4096 で、仕様バージョンは2です。extent はタイルの縦横を整数座標いくつで表すかの宣言で、座標はこの格子に量子化されています。原点の取り方も仕様が決めています。

The upper left corner of the tile (as displayed by default) is the origin of the coordinate system. The X axis is positive to the right, and the Y axis is positive downward.

ジオメトリは MoveTo、LineTo、ClosePath の3コマンドの列で、座標の差分が (value << 1) ^ (value >> 31) のジグザグ符号で詰まっています。

ここで目に付くのは、入っていないものです。線の色も太さも、文字の書体も、どのズームで出すかも、どこにもありません。属性は rdCtg(道路区分)や alti(標高)のような素の値だけです。仕様はタイルに、自分がどこの地図かという情報すら持たせません。

A Vector Tile SHOULD NOT contain information about its bounds and projection.

事実として、タイルは整数座標と属性の入れ物にすぎません。理由は分業です。どこの範囲かはタイル座標の z, x, y が決め、どう見えるかは受け取った側が決めます。だから同じタイルから昼の地図も夜の地図も出せます。逆に、タイルを見ただけでは何色で描かれるはずなのかは分かりません。

転送量も測りました。gzip ありで 70,712 バイト、なしで 178,721 バイトです。最初は --compressed を付けずに叩いて、178,721 バイトが返ってきました。curl はこの指定がないと gzip を要求しないので、サーバー側が圧縮していないように見えたわけです。付け直すと content-encoding: gzip が返りました。ブラウザは常に要求するので、実際の転送は70KB台になります。測るときは、自分のクライアントが何を要求しているかから見ます。

スタイルは別のJSON。だから作り直さずに見た目が変わる

見た目を持っているのはスタイル文書です。MapLibre の定義はこうです。

A MapLibre style is a document that defines the visual appearance of a map: what data to draw, the order to draw it in, and how to style the data when drawing it.

さきほどのタイルに最小のスタイルを当てると、こうなります。

{
  "version": 8,
  "glyphs": "https://example.com/fonts/{fontstack}/{range}.pbf",
  "sources": {
    "gsi": {
      "type": "vector",
      "tiles": ["https://cyberjapandata.gsi.go.jp/xyz/experimental_bvmap/{z}/{x}/{y}.pbf"],
      "minzoom": 4,
      "maxzoom": 16,
      "attribution": "国土地理院"
    }
  },
  "layers": [
    { "id": "bg", "type": "background", "paint": { "background-color": "#f7f5ef" } },
    { "id": "road", "type": "line", "source": "gsi", "source-layer": "road",
      "paint": { "line-color": "#8a8f98", "line-width": 1.2 } },
    { "id": "contour", "type": "line", "source": "gsi", "source-layer": "contour",
      "filter": ["==", ["get", "altiFlag"], 0],
      "paint": { "line-color": "#b09070",
        "line-width": ["step", ["get", "alti"], 0.5, 500, 0.8, 1000, 1.2] } }
  ]
}

これは公式の gl-style-validate に通してエラーなしを確認したものです。ブラウザでの描画までは見ていないので、色の見え方は保証しません。

npx -p @maplibre/maplibre-gl-style-spec gl-style-validate style.json

source-layer がタイルとスタイルの接合部です。仕様の説明は「Layer to use from a vector tile source」で、ベクタータイルのソースでは必須、GeoJSON のソースでは禁止されています。試しに road から外して検証すると止まります。

style-bad.json:1: layers[1]: layer "road" must specify a "source-layer"

レイヤーの型は10種類あります。background、fill、line、symbol、circle、heatmap、fill-extrusion、raster、hillshade、color-relief です。プロパティはどの型も2つに分かれます。layout が並びや向きのような配置の決定、paint が色や不透明度のような塗りです。

スタイルがどれくらいの規模になるかは、公開されているものを数えると分かります。OpenFreeMap の bright を数えたところ、レイヤーは119枚(line 71、symbol 25、fill 22、background 1)。うち116枚が filter を持っていました。タイル側の transportation という1レイヤーを参照しているスタイルレイヤーは、61枚あります。実物はこの形です。

{
  "id": "water",
  "type": "fill",
  "source": "openmaptiles",
  "source-layer": "water",
  "filter": ["all",
    ["!=", ["get", "intermittent"], 1],
    ["!=", ["get", "brunnel"], "tunnel"]],
  "paint": { "fill-color": "#AECFE2" }
}

事実は、タイルの1レイヤーがスタイル側で何十枚にも切り分けられていることです。理由はタイルの容量で、種類をまとめて詰めて、区別は属性に落としてあります。帰結として、スタイルはタイルのスキーマに縛られます。地理院のタイルのレイヤー名は roadbuildinglabel。OpenMapTiles は transportationplacebuildingOpenMapTiles 向けのスタイルを地理院のタイルに当てても、地図は白紙のまま出ます。参照先の名前がどこにもないので、描くものがないという扱いになります。

カスタマイズの入口は4つ

1. スタイルJSONを書き換える。色、太さ、出すズーム範囲、重ね順。タイルは他人が配信しているものをそのまま使えます。テーマを作る作業のほとんどはここで終わります。

2. 属性で分岐させるfilter で出す地物を絞り、式で値から見た目を導きます。さきほどの等高線の例では altistep に渡して、500m と 1000m を境に線の太さを変えています。標高の帯ごとに色を振るのも、道路の等級で太さを変えるのも同じ書き方です。

3. 実行中に差し替える。MapLibre GL JS には setPaintPropertysetLayoutPropertysetFiltersetLayerZoomRange があります。いずれもスタイル側に委譲して再描画を要求するもので、スタイルを丸ごと差し替える必要はありません。スタイル全体を渡す setStyle は、条件が合えば差分適用に回ります。公式サンプルの言語切り替えはこの1行です。

map.setLayoutProperty('label_country_1', 'text-field', ['get', 'name:ja']);

4. グリフとスプライトを差し替える。書体とアイコンはタイルの外にあります。ここは次の節の主題です。

使い分けは、静的なテーマなら1、値で見た目が決まるなら2、利用者の操作で切り替えるなら3です。3を選ぶと状態管理が増えるので、初期表示だけで済む話を実行中の差し替えでやらないほうが後が楽です。

日本語の地図で必ず当たる2つの壁

文字列はタイルの中、字形はタイルの外

さきほどの label レイヤーには knjkana という属性がありました。地名の文字列そのものはタイルに入っています。入っていないのは字形です。スタイルの `glyphs` が持ちます。

A URL template for loading signed-distance-field glyph sets in PBF format.

URLの {fontstack}text-font に書いた書体名に、{range} は256コードポイント単位の範囲に置き換わります。ラテン基本ブロックなら 0-255 です。OpenFreeMap の Noto Sans Regular で、範囲ごとのサイズを測りました。

  • 0-255(ラテン基本): 42,599 バイト
  • 256-511: 55,650 バイト
  • 12288-12543(記号とかな): 107,615 バイト
  • 19968-20223(CJK統合漢字の先頭): 124,376 バイト
  • 20224-20479: 133,701 バイト

CJK統合漢字は U+4E00 から U+9FFF までの20,992文字で、256で割ると82範囲あります。1範囲124KBとして、全部読めば10MB前後という計算になります。実際に読むのは表示に必要な範囲だけですが、日本語のラベルは漢字が広く散るので、範囲の数は素直には減りません。

事実として、タイル1枚が70KB台のところ、漢字のグリフは1範囲で124KBです。理由は形式です。符号付き距離場は字形ごとにテクスチャを持つので、範囲を切って遅延読み込みする設計になっています。帰結は、日本語の地図では転送量の主役がタイルからフォントに移ることです。タイルの軽量化を先に考えても、体感は変わりません。

逃げ道は用意されています。MapLibre GL JS の localIdeographFontFamily に書体名を渡す方法です。漢字とかなを端末のフォントで描き、グリフの取得をやめます。引き換えに書体が端末依存になり、iOS と Android と Windows で見た目が揃わなくなります。この挙動はブラウザで確認していません。私が見たのは公式サンプルと、setStyle の差分適用の判定にこの値が入っている実装までです。

地理院のズームレベルはラスタと1ずれる

提供実験の README にこう書かれています。

画面上で同じ大きさで表示される際のズームレベルは、ベクトルタイルにおける数値が、地理院タイル(ラスタ)のズームレベルと比べて1小さい数値となります。

理由は1枚あたりの面積です。ベクタータイル1枚が、ラスタで1段深いタイル4枚分の範囲を受け持ちます。帰結は2つ。ラスタタイルと重ねると1段ずれること、そしてラスタ向けに書いた maxzoom を写すと1段違う値になることです。

提供されるのは z4 から z16 まで。z17 の情報は z16 に含めてオーバーズーム表示、という設計です。ここでスタイル側の既定値に注意が要ります。vector ソースmaxzoom は既定が22で、仕様の説明はこうです。

Data from tiles at the maxzoom are used when displaying the map at higher zoom levels.

つまり maxzoom を書いておけば、それより深いズームでは同じタイルを拡大して使ってくれます。書かなければ既定の22が採用され、存在しない z17 以降を取りに行って404が並びます。さきほどのスタイル例で maxzoom: 16 を明示しているのはこのためです。

どこから触るか

他人の配信に乗る。地理院のタイルは国土地理院コンテンツ利用規約に従って使えます。ただし位置づけは提供実験で、公式には「実験的に提供するもので、基本測量成果ではありません」と書かれています。README にも断り書きがあります。

提供実験中のデータであるため、URL やデータ構成、データの内容(属性の有無や名称等)が変わる可能性があります。

ftCodealti に依存したスタイルを書くということは、この変更に追随する約束をすることです。四半期ごとの更新のたびに描画を確認する手順を用意できないなら、依存する属性は少なく保ちます。

自前で作る。タイルを生成するなら tippecanoe や planetiler、配信は PMTiles にまとめる形が定番です。

PMTiles is a single-file archive format for pyramids of tiled data.

PMTiles readers use HTTP Range Requests to fetch only the relevant tile or metadata inside a PMTiles archive on-demand.

1枚1ファイルで置くとリクエスト数が課金に直結し、ドキュメントは全球3億枚で1,500 USD という試算を挙げています。範囲リクエストで1ファイルから必要な部分だけ抜く形にすると、この課金が消えます。この構成は私はまだ組んでいないので、ここはドキュメントの記述までです。

MLT を待つかどうか。MapLibre は2026年1月23日に MapLibre Tile を発表しました。列指向のレイアウトと軽量な符号化で、発表記事の言い方では大きなタイルで最大6倍の圧縮率、SIMD と組み合わせられるデコードです。スタイル側は vector ソースの encodingmlt を指定します。既定は mvt です。

いつ使えるのかについては、出典が食い違っています。発表記事は「For the adventurous, the answer is: today」と書き、GL JS と Native の両方が MLT ソースに対応したとしています。一方で仕様リポジトリの README は次の書き方です。

The specification is deemed stable as of October 2025. However, as a living standard, experimental features may continue to evolve.

私は MLT のタイルを生成も描画もしていないので、どちらが実務の判断として正しいかは言えません。既存の MVT の配信を止める理由は、いまのところ見つけていません。

判断の目安を3つ置いて終わります。見た目だけを変えたいなら、書くのはスタイルJSONだけで、タイルは他人のもので足ります。ほしい属性がタイルに入っていないなら、スタイルでは作れないので生成側に回ります。日本語の地図を軽くしたいなら、タイルより先に見直すのはグリフです。

記事の一覧へ