こぼれネット

OSMにアクセスせず(オフラインで)Leaflet地図を表示する」ための、タイル事前ダウンロード手順メモ

以下、「OSMにアクセスせず(オフラインで)Leaflet地図を表示する」ための、タイル事前ダウンロード手順メモ。プログラム/コマンド込み。
(server22-1_v3.go+fetch_tiles.py を前提)


0. ゴール


1. server22-1_v3.go の修正点

1-1. Leaflet のタイルURLをローカルに変更

homeTemplate の地図初期化で、オンラインOSMの行をコメントアウトし、ローカルにする。

// L.tileLayer('http://{s}.tile.osm.org/{z}/{x}/{y}.png', {
//     detectRetina: true,
//     maxNativeZoom: 18
// }).addTo(map);

L.tileLayer('/tiles/{z}/{x}/{y}.png', {
    maxNativeZoom: 18,
    maxZoom: 18
}).addTo(map);

1-2. 右クリックで座標とズームを表示(bbox取得用)

(Goテンプレート内なので JS の `...${}` を使わず、文字列連結にする)

HTML側(<div id="map"></div> の直後):

<div id="coordBox"
     style="position:absolute; left:10px; bottom:10px; z-index:9999;
            background:#fff; padding:6px 10px; border-radius:6px;
            opacity:0.9; font-family:monospace;">
  right-click: lat,lng
</div>

JS側(L.tileLayer(...).addTo(map); の直後):

map.on("contextmenu", function(e) {
    var lat = e.latlng.lat.toFixed(10);
    var lng = e.latlng.lng.toFixed(10);
    var zoom = map.getZoom();
    document.getElementById("coordBox").textContent =
        "zoom=" + zoom + "  lat=" + lat + ", lng=" + lng;
});

1-3. Goサーバで /tiles/ を配信(必須)

main() に1行追加。これが無いと /tiles/*.png がHTMLで返って破綻する。

http.Handle("/tiles/", http.StripPrefix("/tiles/", http.FileServer(http.Dir("./tiles"))))

最終的に main() は概ねこの形:

func main() {
    flag.Parse()
    log.SetFlags(0)

    http.HandleFunc("/echo2", echo2)
    http.HandleFunc("/echo", echo)
    http.HandleFunc("/", home)
    http.HandleFunc("/smartphone", smartphone)

    http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("."))))
    http.Handle("/tiles/",  http.StripPrefix("/tiles/",  http.FileServer(http.Dir("./tiles")))) // 追加

    log.Fatal(http.ListenAndServe(*addr, nil))
}

2. bbox(範囲)と zoom の取得方法(DevTools不要)

bbox(minLon,minLat,maxLon,maxLat)は次で作る:


3. タイル一括ダウンロード用スクリプト(fetch_tiles.py)

目的:--bbox--zooms を指定して tiles/z/x/y.png を保存する。

(既に作成済みの fetch_tiles.py を利用)


4. タイルのダウンロード(実行コマンド)

4-1. 作業ディレクトリ確認(重要)

tiles/ が存在するディレクトリで実行する。

pwd
ls -ld tiles

無ければ作る:

mkdir -p tiles

4-2. 実行(あなたが確定した bbox/zoom 一覧)

[fetch_tiles.py]

#!/usr/bin/env python3
# fetch_tiles.py
#
# 指定した bbox / zoom 範囲の OSM タイルを
# tiles/{z}/{x}/{y}.png 形式でダウンロードする
#
# 使用例:
# python3 fetch_tiles.py \
#   --bbox "130.38,33.58,130.44,33.60" \
#   --zooms 16 17 18 \
#   --sleep 0.3 --retries 3 --verbose

import math
import os
import time
import argparse
import urllib.request
import urllib.error

OSM_TILE_URL = "https://tile.openstreetmap.org/{z}/{x}/{y}.png"

# ------------------------------------------------------------
# 座標変換
# ------------------------------------------------------------

def lonlat_to_tile(lon, lat, z):
    lat = max(min(lat, 85.05112878), -85.05112878)
    n = 2 ** z
    x = int((lon + 180.0) / 360.0 * n)
    lat_rad = math.radians(lat)
    y = int(
        (1.0 - math.log(math.tan(lat_rad) + 1 / math.cos(lat_rad)) / math.pi)
        / 2.0 * n
    )
    return x, y

# ------------------------------------------------------------
# メイン
# ------------------------------------------------------------

def main():
    parser = argparse.ArgumentParser(description="Download OSM tiles to local directory")
    parser.add_argument("--bbox", required=True,
                        help="minLon,minLat,maxLon,maxLat")
    parser.add_argument("--zooms", required=True, nargs="+", type=int,
                        help="zoom levels (e.g. 12 13 14)")
    parser.add_argument("--sleep", type=float, default=0.3,
                        help="sleep seconds between downloads")
    parser.add_argument("--retries", type=int, default=3,
                        help="retry count per tile")
    parser.add_argument("--verbose", action="store_true",
                        help="verbose output")
    args = parser.parse_args()

    minLon, minLat, maxLon, maxLat = map(float, args.bbox.split(","))

    for z in args.zooms:
        x_min, y_max = lonlat_to_tile(minLon, minLat, z)
        x_max, y_min = lonlat_to_tile(maxLon, maxLat, z)

        if args.verbose:
            print(f"[zoom {z}] x:{x_min}-{x_max} y:{y_min}-{y_max}")

        for x in range(x_min, x_max + 1):
            for y in range(y_min, y_max + 1):
                out_dir = os.path.join("tiles", str(z), str(x))
                os.makedirs(out_dir, exist_ok=True)

                out_path = os.path.join(out_dir, f"{y}.png")
                if os.path.exists(out_path):
                    continue

                url = OSM_TILE_URL.format(z=z, x=x, y=y)

                success = False
                for attempt in range(args.retries):
                    try:
                        if args.verbose:
                            print(f"GET {url}")
                        urllib.request.urlretrieve(url, out_path)
                        success = True
                        break
                    except urllib.error.HTTPError as e:
                        if args.verbose:
                            print(f"HTTP error {e.code} for {url}")
                    except urllib.error.URLError as e:
                        if args.verbose:
                            print(f"URL error {e.reason} for {url}")

                    time.sleep(args.sleep)

                if not success:
                    print(f"FAILED: {url}")

                time.sleep(args.sleep)

    print("Done.")

if __name__ == "__main__":
    main()

補足(重要ポイント)

tiles/
  └─ z/
      └─ x/
          └─ y.png

ズームごとに bbox が違うため、ズームごとに1回ずつ実行する。

zoom=12

python3 fetch_tiles.py \
  --bbox "130.0774383545,33.4955977449,130.7324981689,33.7614528514" \
  --zooms 12 \
  --sleep 0.3 --retries 3 --verbose

zoom=13

python3 fetch_tiles.py \
  --bbox "130.2648925781,33.5340974085,130.5920791626,33.6679254426" \
  --zooms 13 \
  --sleep 0.3 --retries 3 --verbose

zoom=14

python3 fetch_tiles.py \
  --bbox "130.3323554993,33.5625675446,130.4964637756,33.6297713135" \
  --zooms 14 \
  --sleep 0.3 --retries 3 --verbose

zoom=15

python3 fetch_tiles.py \
  --bbox "130.3659152985,33.5763700367,130.4476690292,33.6099373508" \
  --zooms 15 \
  --sleep 0.3 --retries 3 --verbose

zoom=16

python3 fetch_tiles.py \
  --bbox "130.364,33.555,130.458,33.614" \
  --zooms 16 \
  --sleep 0.3 --retries 3 --verbose

zoom=17

python3 fetch_tiles.py \
  --bbox "130.380,33.576,130.441,33.603" \
  --zooms 17 \
  --sleep 0.3 --retries 3 --verbose

zoom=18(メモの2行目は右上として扱う)

python3 fetch_tiles.py \
  --bbox "130.391,33.578,130.432,33.599" \
  --zooms 18 \
  --sleep 0.3 --retries 3 --verbose

5. ダウンロード結果の確認

5-1. タイル総数

find tiles -type f | wc -l

5-2. 代表ファイルの存在確認

ls tiles/16/56506 | head

6. 「サーバがPNGを返しているか」の確認(最重要)

サーバ起動後、タイルを1つ直接叩く。

curl -I http://localhost:8080/tiles/16/56506/26267.png

期待:

ここが text/html なら、/tiles/ が FileServer に到達していない(main()の設定ミス)か、./tiles の相対パスがズレている。


7. 実運用(オフラインデモ)


8. よくある失敗と対処

8-1. 画面が灰色+壊れた画像

curl -I ...pngContent-Type: text/html になっている。
main()/tiles/http.Handle(...) が無い、または ./tiles が存在しないディレクトリでサーバを起動している。

8-2. 404 が返る

tiles/z/x/y.png のパスが足りていない(bbox・zoom不足)か、サーバ起動ディレクトリが違う。

8-3. タイル枚数が多すぎる

→ bbox を縮める(ズームが高いほど爆発する)。特に z=18 は狭いbboxに限定する。


このメモの手順で、オンライン依存を切った状態の地図表示が成立する。

モバイルバージョンを終了