DDDDbox Tech Blog

建築設計クラウドサービス『DDDDbox』のテックブログ

国土地理院の地図タイルをReact Three Fiberで3D空間に表示してみた

1. はじめに

AMDlabインターン生の内田です。 今回は国土地理院の地図タイルとそれをReact Three Fiber(R3F)の3D空間に表示する方法について紹介します。

2. 国土地理院とは

国土地理院公式サイトによると、 「国土地理院は、我が国唯一の国家地図作成機関であり、国土交通省の特別の機関です」 と宣言されています。 国土地理院は、測量・地図の作成・地理空間情報の整備を行い、さらに日本全国の地理情報を公開しています。

3. 地理院タイルについて

地理院タイルとは、国土地理院が配信する標準地図・航空写真などのタイル状の地図データです。 ウェブサイトやアプリで利用する場合、出典を明示すれば申請不要で利用できます。*1

1枚のタイルは256×256ピクセルで、ズームレベル(z)に応じて地図全体が細かく分割されています。
各タイルには (x, y, z) のインデックスが割り当てられており、URLは次のように指定できます:https://cyberjapandata.gsi.go.jp/xyz/{t}/{z}/{x}/{y}.{ext}

  • t:データID
  • z:ズームレベル
  • x:タイル座標のX値
  • y:タイル座標のY値
  • ext:拡張子

例えば、標準地図でズームレベル16の東京駅だったら、次のようなURLになります:https://cyberjapandata.gsi.go.jp/xyz/std/16/58211/25806.png

地球上の緯度経度をメルカトル投影を使ってタイル座標に変換しています。*2

4. 東京駅の地図をReact Three Fiberで表示してみる

主な使用ライブラリは、React + Three.js + @react-three/fiber です。

結果

このような表示になります。

実装全体の流れ

  1. 東京駅の座標をもとに対応する地図タイルを計算
  2. 対応する地図タイルの周囲8タイルを取得(合計3x3枚)
  3. 取得した画像を合成し、1枚のテクスチャとして作成
  4. メッシュに貼り付けて表示

地理院タイルについての実装

import { useEffect, useMemo, useState } from "react";
import * as THREE from "three";

export type Layer = "std" | "seamlessphoto";

/**
 * lonLatToTile:
 * 緯度経度(lon, lat)とズームレベル z からタイル座標 (x, y) を計算する。
 */
function lonLatToTile(lon: number, lat: number, z: number) {
  const latRad = (lat * Math.PI) / 180;
  const n = 2 ** z;
  const xFloat = ((lon + 180) / 360) * n;
  const yFloat =
    ((1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2) *
    n;

  return {
    z,
    x: Math.floor(xFloat),
    y: Math.floor(yFloat),
  };
}

/**
 * urlFor:
 * レイヤー(標準地図 or 航空写真)に応じたタイル画像URLを生成する。
 */
function urlFor(layer: Layer, z: number, x: number, y: number) {
  return layer === "seamlessphoto"
    ? `https://cyberjapandata.gsi.go.jp/xyz/seamlessphoto/${z}/${x}/${y}.jpg`
    : `https://cyberjapandata.gsi.go.jp/xyz/std/${z}/${x}/${y}.png`;
}

/**
 * loadImage:
 * 指定URLから画像を読み込み、HTMLImageElementをPromiseで返す。
 */
function loadImage(url: string): Promise<HTMLImageElement> {
  return new Promise((resolve, reject) => {
    const img = new Image();
    img.crossOrigin = "anonymous";
    img.onload = () => resolve(img);
    img.onerror = () => reject(new Error(`画像の取得に失敗しました: ${url}`));
    img.src = url;
  });
}

type Props = {
  /**
   * lat:
   * 表示したい地点の緯度。例: 35.681236。
   */
  lat: number;
  /**
   * lon:
   * 表示したい地点の経度。例: 139.767125。
   */
  lon: number;
  /**
   * z:
   * ズームレベル。既定値は16。
   */
  z?: number;
  /**
   * layer:
   * 地図の種類。"std"(標準地図)または"seamlessphoto"(航空写真)。
   */
  layer?: Layer;
  /**
   * tileSize:
   * 1タイルあたりのピクセル数。既定値は256px。
   */
  tileSize?: number;
  /**
   * range:
   * 取得するタイル範囲。1なら中心+8枚で3×3表示。
   */
  range?: number;
  /**
   * worldTile:
   * 3D空間上で1タイルを何単位で描画するかのスケール係数。
   */
  worldTile?: number;
  /**
   * y:
   * 地図メッシュを地面からどれくらい浮かせるか。重なり回避用。
   */
  y?: number;
};

/**
 * ChiriinMapMesh:
 * 指定座標を中心に range のタイルを取得・合成し、CanvasTextureを貼った平面メッシュとして表示する。
 */
export function ChiriinMapMesh({
  lat,
  lon,
  z = 16,
  layer = "std",
  tileSize = 256,
  range = 1,
  worldTile = 10,
  y = 0.001,
}: Props) {
  const [texture, setTexture] = useState<THREE.Texture | null>(null);

  useEffect(() => {
    let aborted = false;
    (async () => {
      try {
        const { x: cx, y: cy } = lonLatToTile(lon, lat, z);
        const tiles: { x: number; y: number }[] = [];
        for (let dy = -range; dy <= range; dy++) {
          for (let dx = -range; dx <= range; dx++) {
            tiles.push({ x: cx + dx, y: cy + dy });
          }
        }

        const urls = tiles.map((t) => urlFor(layer, z, t.x, t.y));
        const images = await Promise.all(urls.map(loadImage));
        if (aborted) return;

        const count = range * 2 + 1;
        const canvas = document.createElement("canvas");
        canvas.width = count * tileSize;
        canvas.height = count * tileSize;
        const ctx = canvas.getContext("2d");
        if (!ctx) throw new Error("キャンバスのコンテキスト取得に失敗しました");

        images.forEach((img, i) => {
          const col = i % count;
          const row = Math.floor(i / count);
          ctx.drawImage(img, col * tileSize, row * tileSize, tileSize, tileSize);
        });

        const tex = new THREE.CanvasTexture(canvas);
        tex.anisotropy = 16;
        tex.colorSpace = THREE.SRGBColorSpace;
        if (!aborted) setTexture(tex);
      } catch (error) {
        console.error(error);
        setTexture(null);
      }
    })();

    return () => {
      aborted = true;
    };
  }, [lat, lon, z, layer, tileSize, range]);

  const planeSize = useMemo(
    () => (range * 2 + 1) * worldTile,
    [range, worldTile]
  );

  if (!texture) return null;

  return (
    <mesh position={[0, y, 0]} rotation={[-Math.PI / 2, 0, 0]} renderOrder={1}>
      <planeGeometry args={[planeSize, planeSize]} />
      <meshBasicMaterial map={texture} polygonOffset polygonOffsetFactor={-1} />
    </mesh>
  );
}

5. まとめ

今回は、国土地理院が提供する地理院タイルをReact Three Fiberで3D空間に表示してみました。

タイル座標 (x, y, z) の計算など一部の処理は少しややこしいものの、
上記のサンプルのように「地図を取得して3D空間に貼り付ける」だけなら比較的シンプルに実装できます。

一方で、ナビアプリのように、ズームに応じてタイルを切り替える、カメラ移動に合わせてタイルを動的に取得する、 大量の地図データをリアルタイムに扱うといった高度な機能が必要な場合は、 自分で仕組みを構築するよりMapboxなどの地図専用サービスを利用する方が早く実装ができると思います(本記事では紹介しませんが、便利です)。

ただ、このようなサービスは内部処理がブラックボックス化されやすく、
距離計算や独自オブジェクトとの連携など、地図の上で自分の仕組みを作り込みたい場合は扱いにくいこともあります。

その点、React Three Fiberを使えば地図そのものを3Dオブジェクトとして扱えるため、
「地図上に独自のオブジェクトを置きたい」「マップ編集ツールのようなUIを作りたい」
といった用途では特に相性が良いと思います。

国土地理院の地図を素材として扱う方法の参考になれば幸いです。
国土地理院の地図データについての興味がある方は公式の地理院地図を触ってみるといいかもしれません。👉 https://maps.gsi.go.jp/

出典

出典:国土地理院地理院タイル」(https://maps.gsi.go.jp/development/ichiran.html)

※本記事の表示例では、地理院タイル画像を合成・加工して作成しています。