Mini Tokyo 3D バージョン 4.0.0 リリース

Mini Tokyo 3D バージョン 4.0.0 がリリースされました (GitHub)。前回のリリースから1年8ヶ月、動いているものを壊さずに改良していくのはなかなか大変でしたが、最近はコーディングエージェントのおかげでだいぶ楽になりました。バージョン 3.x からのメジャーアップグレードということで、多くの変更や改善(開発者向け API の互換性に影響のあるものを含む)が入っています。

それでは、3.6.0 からの追加・修正機能を見ていきましょう。

タイルセットとマップスタイルを刷新

Mapbox GL JS v3 へのアップグレードに伴い、著名ランドマークの 3D モデル、樹木、シャドウ、よりリアルなライティングでデジタルツインが表現されます。こちらは新宿の夜景です。美しいですね!

さらに、新しいタイルセットでは高精細道路データが配信されます。都心部限定ですが、高架橋、アンダーパス、橋梁、車線ペイントまでよくわかります。

フォグの追加と大気色の調整

マップスタイルの更新と同時に、フォグの追加と大気色の調整が行われました。地平線に近いところがスムーズにぼやけていきます。ベースマップだけではなく、列車や旅客機のオブジェクト、PLATEAU プラグインで表示されるテクスチャ付き建築物にも同じようにフォグの効果が効きます。

ライティングの更新

環境光、太陽光(並行光源)を組み合わせたより現実的なライティングに変更。ベースマップ、3D オブジェクト、テクスチャ付き建築物に対するライト設定は同期されており、自然な見た目になるようにガンマ補正を追加しています。特に、日の出・日の入りの時間の情景は見ものです。

列車、旅客機、バスの位置計算を GPU で行い描画がスムーズに

列車、旅客機、バス車両オブジェクトの位置計算を GPU 側に持っていくことで大きな性能向上を達成しました。下の図は毎フレームごとの処理の概要ですが、JavaScript なのでシングルスレッドの逐次処理しかできなかったオブジェクトの位置計算が、GPU で並列化されるようになったということです。いわゆる GPGPU 処理です。

駅の共有ボタン

追跡している列車やフライトの共有機能は前からありましたが、選択している駅を他の人に共有できるようになりました。待ち合わせの駅を、LINE や AirDrop で知り合いの方に通知してみてください。

GTFS ローディングの改善

URL のクエリパラメータで GTFS データを指定した場合に、デフォルトの列車データはデータソースから除外され、指定した GTFS データのみが表示されるようになりました。

また、GTFS のロード処理が改善され、多くのデータがエラーなく表示できるようになりました。

GTFS のタイムゾーン対応

GTFS データソースを指定した場合に、GTFS データで設定されているタイムゾーンに合わせて日時表示に使われるタイムゾーンが使われるようになりました。これにより、海外の GTFS 事業者のデータを見る場合でも、自動的に現地時刻で表示できるようになります。

ただし、デフォルトの列車データがデータソースに含まれている場合はそちらが優先されて日本標準時になります。また、複数の GTFS データが追加された場合には、最初に指定されたデータのタイムゾーンが有効になります。

GTFS ルートカラー・停留所の出発時刻の表示

GTFS 車両にマウスを当てた時と追跡している時に、走行ルートを運行系統カラーでハイライトするように改良しました。さらに、停留所リストのラインも運行系統カラーに変更し、各停留所の出発予定時刻が表示されるようになりました。

GTFS の静的データのみの表示にも対応

GTFS 静的データのみのデータセットに対応しました。GTFS Realtime フィードを指定しない場合は時刻表通りの運行をします。これに伴い、GTFS 運行車両の再生モード(タイムラプス)表示にも対応しています。

花火プラグインの改善

花火プラグインの物理シミュレーションのロジックを1から作り直しました。大半の処理を GPU に移したことでパフォーマンスが大幅に向上。牡丹、パレット(多色版牡丹)、しだれ柳、土星、芯入り、きらめきの6種の美しい花火がランダムに上がります。

PLATEAU プラグインの改善

PLATEAU プラグインが 3D Tiles 1.1 形式の読み込みに対応しました。すでに、2025年度以降に整備された PLATEAU データの一部は 1.1 形式になっているとのことです。例えば台東区は全域がテクスチャ付きになっていてすごいですね。雷門の大提灯や仲見世商店街などは、高精細なテクスチャを用意しています。

合わせてビルのマテリアル設定を変更、オルソ画像の彩度を調整し、タイルロードの不具合修正・効率化しています。

1年半前にデータを更新した時は、テクスチャ付き LOD2 に対応しているのは213市区町村でしたが、現時点では300市区町村に増えています。国土交通省は2027年度までに500都市での 3D 都市モデル整備完了を目指しています。

対応路線・エリアがさらに拡大

対応路線・エリアがさらに拡大し、これまでの路線を含めると合計179路線(支線・連絡線含む)、2,016駅、平日の列車本数は49,093本になりました。

路線 説明
JR 中央本線 大月〜甲府間を追加
JR 宇都宮線 宇都宮〜黒磯間を追加
JR 上越線 高崎〜水上間を追加
JR 常磐線 水戸〜高萩間を追加
JR 水郡線 水戸〜常陸大子間、上菅谷〜常陸太田間が新規開通
JR 日光線 宇都宮〜日光間が新規開通
JR 烏山線 宝積寺〜烏山間が新規開通
JR 信越本線 高崎〜横川間が新規開通
JR 吾妻線 渋川〜大前間が新規開通
JR 御殿場線 国府津〜沼津間が新規開通
小田急箱根鉄道線 小田原〜強羅間が新規開通
銚子電気鉄道線 銚子〜外川間が新規開通
鹿島臨海鉄道大洗鹿島線 水戸〜鹿島神宮間が新規開通
ひたちなか海浜鉄道湊線 勝田〜阿字ヶ浦間が新規開通
真岡鐵道真岡線 下館〜茂木間が新規開通
宇都宮芳賀ライトレール線 宇都宮駅東口〜芳賀・高根沢工業団地間が新規開通
伊豆箱根鉄道大雄山線 小田原〜大雄山間が新規開通
伊豆箱根鉄道駿豆線 三島〜修善寺間が新規開通
伊豆急行線 伊東〜伊豆急下田間が新規開通
富士急行線 大月〜河口湖間が新規開通
上毛電気鉄道上毛線 中央前橋〜西桐生間が新規開通
上信電鉄上信線 高崎〜下仁田間が新規開通
わたらせ渓谷鐵道わたらせ渓谷線 桐生〜間藤間が新規開通
ダイヤ改正対応

次のダイヤ改正に対応しました。

  • 東京メトロ丸の内線の2024年12月7日ダイヤ改正
  • 日暮里舎人ライナーの2025年2月3日ダイヤ改正
  • 首都圏各線の2025年3月15日一斉ダイヤ改正
  • 山万ユーカリが丘線の2025年4月1日ダイヤ改正
  • ゆりかもめの2025年10月18日ダイヤ改正
  • 東京メトロ銀座線、横浜市営地下鉄の2025年11月1日ダイヤ改正
  • 都営浅草線、京急、京成、北総鉄道、芝山鉄道の2025年12月15日ダイヤ改正
  • 江ノ電の2025年12月20日ダイヤ改正
  • 首都圏各線の2026年3月14日一斉ダイヤ改正
  • 宇都宮ライトレールの2026年4月1日ダイヤ改正
  • 山万ユーカリが丘線の2026年5月1日ダイヤ改正
ライブカメラの拡充

ライブカメラプラグインで表示される、沿線のライブカメラがさらに追加されました。

  • 本八幡ライブカメラ
  • 両国ライブカメラ
  • 安針塚ライブカメラ
  • 追浜ライブカメラ
  • 田端ライブカメラ
  • 箱根湯本ライブカメラ
  • 河津ライブカメラ
  • 河口湖ライブカメラ
  • 寿ライブカメラ
  • 春日居町ライブカメラ1
  • 春日居町ライブカメラ2
  • 春日居町ライブカメラ3
  • 葭川公園ライブカメラ
  • 足柄(静岡)ライブカメラ
  • 岡本ライブカメラ
  • 鎌ヶ谷ライブカメラ
  • 京急鶴見ライブカメラ
  • 市場前ライブカメラ
  • 面影橋ライブカメラ
  • 小田原ライブカメラ
  • 東雲ライブカメラ2
ユーザーガイド・開発者ガイドがフランス語、簡体中国語に対応

コミュニティーのコントリビューションにより、ユーザーガイド・開発者ガイドがフランス語、簡体中国語に対応しました。

データソースの統合(API 互換性に影響する変更)

バージョン 3.x では Map コンストラクタの options.dataSources は、組み込みの列車・フライトデータに加えて追加する GTFS データソースを指定するものでした。バージョン 4.0.0 では組み込みの ODPT および Mini Tokyo 3D データ自体が dataSources の要素となり、渡した dataSources がそのデフォルトの集合を置き換えます。組み込みデータを残したまま独自のソースを追加するには、dataSources を指定せずに実行時に Map#addDataSource で追加する(組み込みソースは削除されません)か、組み込みソースと独自ソースをまとめて dataSources 配列に含めてください。

詳しくは、開発者ガイドをご覧ください。

アセットの配信方法の変更(互換性に影響する変更)

従来 Map コンストラクタの options.dataUrl から配信されていたマップスタイルとローカライズ辞書のファイル(osm-liberty.json および dictionary-<lang>.json)は、バージョン 4.0.0 ではバンドルと同じ場所にある assets フォルダから読み込まれるようになりました(style.json および dictionary-<lang>.json)。jsDelivr CDN を使う場合は自動的に配信されます。Mini Tokyo 3D を自分のアプリケーションにバンドルする場合は、dist に含まれる assets フォルダをバンドルと同じ場所に配置してください。gz 圧縮データは引き続き options.dataUrl から読み込まれます。また、新しいスタイルは以前のものと互換性がないため、それをベースにしたカスタムスタイルは作り直す必要があります。

詳しくは、開発者ガイドをご覧ください。

ライブラリ配信形式の変更(互換性に影響する変更)

バージョン 4.0.0 では、ライブラリの配信形式を見直しました。従来の開発用 UMD ビルド(非圧縮の .js)を廃止し、代わりに Node.js やバンドラ向けの専用 CommonJS ビルド(.cjs、main フィールド)を追加、あわせて ES モジュールビルド(.esm.js、module フィールド)にはソースマップを同梱しました。ブラウザの <script> 読み込み用には、これまでどおり UMD ビルド(.min.js、グローバル変数 mt3d)を提供します。

メソッド・イベントの追加

バージョン 4.0.0 では、Map クラスに次のメソッドが追加されました。

メソッド 説明
addDataSource マップにデータソースを追加
getLight マップに現在設定されているライトを取得
removeDataSource データソースをマップから削除

また、Map クラスに次のイベントが追加されました。

イベント 説明
light ライトが変化したときに発生

さらに、ThreeLayerInterface インターフェースに次のメソッドが追加されました。

メソッド 説明
prerender シーンが描画される前に呼び出される任意実装のメソッド
render レイヤーを描画するために呼び出される任意実装のメソッド

詳細は API リファレンスをご覧ください。

Mini Tokyo 3D バージョン 3.6.0 リリース

Mini Tokyo 3D バージョン 3.6.0 がリリースされました (GitHub)。3.5.0 からの追加・修正機能を見ていきましょう。

実験的に GTFS/GTFS-RT をサポート

実験的機能として、Mini Tokyo 3D がついに GTFS/GTFS-RT 形式の交通機関に対応しました。Mini Tokyo 3D にアクセスする際の URL の末尾に、「?」に続けてキーと値のペア(クエリパラメータ)を記述することで、特定の GTFS データセットおよび GTFS Realtime VehiclePosition フィードを指定します(詳細な説明)。

https://minitokyo3d.com/?gtfsurl=<GTFS zipファイル>&gtfsvpurl=<GTFS-RT VehiclePosition>&gtfscolor=<色コード>

今のところ、shapes.txt を含んでおり GTFS-RT VehiclePosition が提供されている交通機関のリアルタイム表示のみの対応です。例えば都営バス (東京) の場合は次の通りです。

https://minitokyo3d.com/?gtfsurl=https%3A%2F%2Fapi-public.odpt.org%2Fapi%2Fv4%2Ffiles%2FToei%2Fdata%2FToeiBus-GTFS.zip&gtfsvpurl=https%3A%2F%2Fapi-public.odpt.org%2Fapi%2Fv4%2Fgtfs%2Frealtime%2FToeiBus&gtfscolor=9FC105

使用メモリ量の削減

内部のデータクラスの整備とデータ構造の最適化により、JavaScript の使用ヒープサイズを約26%削減しました。

PLATEAU プラグインが全国に対応

PLATEAU プラグインをアップデートしました。2023年4月の PLATEAU View 2.0 向けに更新された配信データに対応しています。位置に合わせて動的にデータをロード/アンロードすることでテクスチャ付き LOD2 に対応した全国213都市の表示に対応。降雨アニメーション表示時の不具合も修正しました。

ドイツ語サポート

コミュニティーのコントリビューションにより、ドイツ語に対応しました。

JR 両毛線/水戸線/東海道線熱海〜沼津間追加

JR 両毛線、水戸線が全線開通しました。また、東海道線の熱海〜沼津間が開通。熱海〜函南間は総延長7,804メートルの丹那トンネルを通過します。熱海での踊り子号の分割併合や、サンライズ瀬戸・出雲の走行が見られます。さらに、JR 東日本管内では現在最も長い距離(前橋〜沼津241km)を走る普通列車 1943E を全区間追跡できるようになりました。

ダイヤ改正対応

都営浅草線、京急、京成、北総鉄道、芝山鉄道、新京成の 2024/11/23 ダイヤ改正に対応しました。

ズームレベルによるライブカメラアイコンの表示切り替え

ライブカメラが増えてきたため、小縮尺時(ズームレベルを下げた時)にライブカメラアイコンを非表示にするようにしました。また、この改良に伴い Marker クラスの次のコンストラクタオプションで最小ズームレベルがサポートされました。

プロパティ 説明
minZoom マーカーの最小のズームレベル

詳細は API リファレンスをご覧ください。

ライブカメラの拡充

ライブカメラプラグインで表示される、沿線のライブカメラがさらに追加されました。

  • 東神奈川ライブカメラ1
  • 東神奈川ライブカメラ2
  • 七里ヶ浜ライブカメラ2
  • 秋葉原ライブカメラ2

CSP で制限されたページで Mapbox GL JS を使うには

この記事は Mapbox Advent Calendar 2024 の5日目の記事です。

コンテンツセキュリティポリシー (CSP)

コンテンツセキュリティポリシーとは、特定の種類の攻撃による影響を軽減するために追加できるセキュリティのしくみです。クロスサイトスクリプティング (XSS) をはじめとする様々なウェブセキュリティ脆弱性への対策として有効です。レスポンスヘッダーや <meta> 要素にポリシーを指定することで有効になります。

Mapbox 公式ドキュメントでも、Mapbox GL JS を使う際に指定すべき CSP ディレクティブが明示されています。

ディレクティブ ソース 説明
worker-src blob: Worker、SharedWorker、ServiceWorker スクリプトのソースを blob: に限定
child-src blob: ウェブワーカーと <frame> や <iframe> などの要素を使用して読み込んだコンテンツのソースを blob: に限定
img-src data: blob: 画像やファビコンのソースを data:、blob: に限定
connect-src https://*.tiles.mapbox.com https://api.mapbox.com https://events.mapbox.com fetch() や XMLHttpRequest などのスクリプトインターフェイスを使用して読み込むことができる URL を限定

厳格な CSP 環境では

各ディレクティブに少なくとも上記のソースが指定されていれば Mapbox GL JS は問題なく動作するというものですが、このうち worker-src、child-src で必要な blob: の指定は、環境によっては許可されていない場合があります。

Content Security Policy Level 2 仕様の 4.2.2.1 の項に記載がありますが、blob: のような URL が指すコンテンツは、レスポンスボディから派生したり、Document コンテキストで実行されたりするため、安全でない可能性があるためです。blob: を許可することは、'unsafe-eval' と等価とされています。eval() がなんとなく危険、というのは聞いたことがありますよね?

Mapbox GL JS は通常、1つの JavaScript ファイル内でウェブワーカーを生成し、Blob を使って読み込むため、このような環境ではそのままでは動作しません(ウェブワーカーは UI 操作を邪魔することなくバックグラウンドでタイルのローディングなどを行うために使われる)。

そこで、mapbox-gl.js の代わりにウェブワーカーコードを除いた mapbox-gl-csp.js ファイルを使用し、ワーカーファイル mapbox-gl-csp-worker.js へのパスを別途手動で設定する必要があります。ワーカーファイルは同一オリジンポリシーに従わなければならないことに注意してください。つまり、mapbox-gl-csp-worker.js はそれを読み込むページと同じオリジンから提供されなければなりません。

<script src="https://api.mapbox.com/mapbox-gl-js/v3.8.0/mapbox-gl-csp.js"></script>
<script>
mapboxgl.workerUrl = 'https://api.mapbox.com/mapbox-gl-js/v3.8.0/mapbox-gl-csp-worker.js';
/* ... */
</script>

さらに特殊な環境では

さて、ここまでは公式ドキュメントにも説明がありますし、ほとんどの環境ではこれ以上の対応は必要ないかと思います。しかし、ここからが本番です(笑)。

私の目の前にあるのは、 HTML/CSS と JavaScript を与えてやれば <iframe> の中で動的なページを表示できる、いわゆるサンドボックス環境です。ここでは、埋め込みページの <meta> 要素で、child-src 'none'(そして worker-src は無し、つまり child-src にフォールバック)という CSP が適用されており、これは変更することはできません。その上、JavaScript 実行時に渡ってくる window オブジェクトからは、律儀なことに Worker クラスが消されています。意地でもウェブワーカーを使わせないぞという強い意志を感じます。

それでも Mapbox GL JS を使って地図を表示したいので、色々こねくり回して作り上げたコードがこちらです。これを動かすには、https://api.mapbox.com/mapbox-gl-js/v3.8.0/mapbox-gl-csp-worker.js の中身と、Mapbox アクセストークンをそれぞれ指定の場所に、コピペしておく必要があります。

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src http: https: 'unsafe-inline'; style-src http: https: 'unsafe-inline'; img-src http: https: data:; font-src http: https:; connect-src http: https:; media-src http: https:; object-src 'none'; child-src 'none'; frame-src 'none'">
<title>Mapbox GL JS map</title>
<meta name="viewport" content="initial-scale=1,maximum-scale=1,user-scalable=no">
<link href="https://api.mapbox.com/mapbox-gl-js/v3.8.0/mapbox-gl.css" rel="stylesheet">
<script src="https://api.mapbox.com/mapbox-gl-js/v3.8.0/mapbox-gl-csp.js"></script>
<style>
body { margin: 0; padding: 0; }
#map { position: absolute; top: 0; bottom: 0; width: 100%; }
</style>
</head>
<body>
<div id="map"></div>
<script>
mapboxgl.workerClass = (function(window) {
  const WW_CONTEXT_WHITELIST = [
    'createImageBitmap', 'fetch', 'ImageBitmap', 'ImageData',
    'OffscreenCanvas', 'Request', 'AbortController'
  ];

  return function() {
    const me = this;
    let worker_context;

    // Allow main thread to specify event listeners
    const ui_listeners = {};
    this.addEventListener = (event_name, fn) => {
      // listen for events from worker thread
      if (!ui_listeners[event_name])
        ui_listeners[event_name] = [];
      ui_listeners[event_name].push(fn);
    };

    // onmessage handler
    this.addEventListener('message', e => {
      if (typeof me.onmessage !== 'undefined') {
        me.onmessage(e);
      }
    });

    function WorkerGlobalScope() {}

    /**** Worker context accessible to worker *****/
    function WorkerContext() {
      const worker_listeners = {};
      this.addEventListener = (event_name, fn) => {
        // listen for events from UI thread
        if (!worker_listeners[event_name])
          worker_listeners[event_name] = [];
        worker_listeners[event_name].push(fn);
      };

      // onmessage handler
      this.addEventListener('message', e => {
        if (typeof worker_context.onmessage !== 'undefined') {
          try {
            worker_context.onmessage(e);
          } catch (error) {
            triggerEvent(ui_listeners, 'error', error, true);
          }
        }
      });

      this.postMessage = msg => {
        triggerEvent(ui_listeners, 'message', msg);
      };

      this.__processPostMessage = msg => {
        triggerEvent(worker_listeners, 'message', msg);
      };

      this.close = () => {};

      // window context
      for (const p of WW_CONTEXT_WHITELIST) {
        if (typeof window[p] === 'function' && !window[p].prototype) {
          this[p] = window[p].bind(window);
        } else {
          this[p] = window[p];
        }
      }
      this.WorkerGlobalScope = WorkerGlobalScope;
    }
    WorkerContext.prototype = new WorkerGlobalScope();
    worker_context = new WorkerContext();
    this.postMessage = msg => {
      worker_context.__processPostMessage(msg);
    };

    this.terminate = () => {};

    function triggerEvent(listeners_map, event_name, event_data, no_wrapping) {
      const event_obj = no_wrapping ? event_data : {data: event_data};

      if (!listeners_map[event_name]) return;
      for (const listener of listeners_map[event_name]) {
        listener(event_obj);
      }
    }

    const mask = {};

    // worker context
    for (const p in worker_context) {
      mask[p] = worker_context[p];
    }
    // set self context
    mask['self'] = worker_context;

    with(mask) {
      /* ここに https://api.mapbox.com/mapbox-gl-js/v3.8.0/mapbox-gl-csp-worker.js をコピペ */
    }
  }
})(window);

mapboxgl.accessToken = 'ここに Mapbox アクセストークンを入れる';

const map = new mapboxgl.Map({
  container: 'map',
  center: [139.7670, 35.6814],
  zoom: 16,
  pitch: 60
});
</script>
</body>
</html>

これ、一体何をしているかというと、Worker クラスが存在しないので、mapbox-gl-csp-worker.js を内蔵する擬似 Worker クラスを作って、メインスレッドで動かしてしまうという力技です。ウェブワーカー未対応のブラウザで Worker コードを動かすためのこのライブラリを参考にしました。

ウェブワーカーと違ってマルチスレッドで動かないのでややぎこちないですが、動作としてはほぼ問題ないのではないでしょうか。惜しいのは、Mapbox GL JS v3 で導入された 3D ランドマークに使われている batched-model が表示されないところですね。これはローディングに WebAssembly コードを使っているため、script-src 'unsafe-eval' が必要になってしまいます(これ、ウェブワーカーとして実行する場合には制限にかからないのですが、どういうしくみでしょうね)。

このテクニックを使用した、続きの記事を書きたいと思いますので、お楽しみに!

Mini Tokyo 3D × PLATEAU 2024年版

この記事は 3D都市モデル Project PLATEAU Advent Calendar 2024 の3日目の記事です。

2年前に Mini Tokyo 3D × PLATEAU という記事を書きました。その後、2023年4月に PLATEAU VIEW 2.0 のリリースとそれに合わせた配信データの更新があり、おそらくそのタイミングで 3D Tiles の仕様変更があったため、上記の記事の内容は新しいデータでは動かなくなっていました。さらに、2024年5月には PLATEAU VIEW 3.0 のリリースと配信データ更新、データカタログ API の公開もあり、「ここらで Mini Tokyo 3D の PLATEAU プラグインも最新のデータに合わせて刷新するか」と思い立ち色々試してみたのが本記事の内容です。2024年11月6日の PLATEAU LT 07 でのトークの詳細でもあります。

ちなみに前回の記事をご覧いただいてない方向けに簡単に解説すると、Mini Tokyo 3D は東京の公共交通のリアルタイム 3D マップで、Mapbox を地図ライブラリとして利用しているウェブアプリケーションです。PLATEAU plugin for Mini Tokyo 3D は Mini Tokyo 3D 上で動作するプラグインで、PLATEAU配信サービス(試験運用) チュートリアルで配信されているデータを利用して、テクスチャ付き建築物のリアルな 3D 風景の中を列車や旅客機が移動する様子を楽しめます。

データ仕様変更への対応

まず、以前は README に列挙されていた 3D Tiles の URL ですが、現在はデータカタログ API を使って取得する形になっています。https://api.plateauview.mlit.go.jp/datacatalog/plateau-datasets をコールすると 3D Tiles/MVT のデータ一覧が JSON のレスポンスとして返ってくるので、その中の url フィールドを使います。

次に、更新されたデータでは建築物の表示ができなくなっていましたので、それに対応する必要があります。Tile3DLayer の onTileLoad に引数として渡ってくるデータと onTileLoad での処理内容を比べると、前回は content.batchTableJson._gml_id の長さから項目数を取得していましたが、_gml_id が gml_id に名称変更されていたためにエラーになっていることがわかりました。gml_id の長さを取得するように変更してもいいのですが、content.featureTableJson.BATCH_LENGTH を使う方がより良いので、そのように変更します。また、Tileset によっては content.batchTableJson._zmin が存在しないこともあるので、そのチェックを入れています。

onTileLoad: ({content}) => {
    const zmin = content.batchTableJson._zmin;
    const cartographicOrigin = content.cartographicOrigin;

    if (zmin) {
        const buffer = content.batchTableBinary.buffer;
        const len = content.featureTableJson.BATCH_LENGTH;
        const zMinView = new DataView(buffer, zmin.byteOffset, len * 8);
        const zMins = [];

        for (let i = 0; i < len; i++) {
            zMins.push(zMinView.getFloat64(i * 8, true));
        }
        zMins.sort((a, b) => a - b);
        cartographicOrigin.z -= zMins[Math.floor(len / 2)];
    }
    cartographicOrigin.z -= 36.6641;
}

とりあえずこれでエラーは解消し、選択した 3D Tiles のモデルを Tile3DLayer で表示できるようになりました。が、相変わらずブラウザで表示させるには重たいです。

動的なデータのロード/アンロード

前回はロードしておく 3D Tiles タイルセットは都内6つの区に限定していて、それでもメモリは不足気味でした。そこで、見えている部分の必要なモデルだけ動的にロードしてメモリを使うようにすればよいだろうという考えのもと、次の方針を立てました。

  • 事前に 3D Tiles の URL と領域を表す GeoJSON ポリゴンを用意
  • 地図が移動されたら、ポリゴンを使ってビューポート内の領域を特定
  • 新たに表示領域になった 3D Tiles をロードし、非表示になった領域の 3D Tiles をアンロード

もう表示対象は全国の都市の 3D Tiles にしてしまえということで、上述のデータカタログ API で取得してきた JSON から、データタイプが建築物(type_en が 'bldg')かつ LOD が 2(lod が 2)かつテクスチャ付き(texture が存在)のデータセットを抜き出します。そして市区町村コード(ward_code または city_code)と URL(url)を取得します。2024年12月現在、これは213市区町村の分があります。

次に、国土数値情報ダウンロードサイトの行政区域データのページから、2023年版全国データ N03-20230101_GML.zip をダウンロードします。最新の2024年版ではダメです。というのも、PLATEAU の 3D Tiles の最新データは2023年度に整備されており、その後浜松市で行政区の再編があって市区町村コードが一致しなくなるからです。

N03-20230101_GML.zip の中には N03-23_230101.geojson というGeoJSON ポリゴンデータが入っており、各 Feature のプロパティに含まれる行政区域コード(N03_007)を参照すれば、3D Tiles とポリゴンデータを対応づけることができます。

さて、このポリゴンデータを使えば、見えている地図の範囲に対象の行政区域が含まれるかを判定できそうですが、例えば地図の中心の座標を判定に使ったとすると、ポリゴンの領域が少しでも中心からずれれば範囲外と判定されてしまいます。つまり、地図上の半分くらいモデルが表示されない状態ができてしまう可能性があります。これに対処するために、ポリゴン領域を少し拡大して、隣り合うポリゴンをある程度オーバーラップさせることで、地図の中心から多少離れても領域内と判定させることができます。

このポリゴン領域の拡張には Python のライブラリ Shapely の buffer 関数を使います。また、正確な領域の判定は不要なので、simplify 関数を使って頂点数を削減します。最後に拡張したポリゴンに、市区町村コードと 3D Tiles の URL をプロパティとして付けて、GeoJSON 形式で出力します。ここまでの一連の処理を行う Python スクリプトは次の通りです。

import json
import requests
from shapely import from_geojson, to_geojson, from_wkt, to_wkt, MultiPolygon, get_coordinates

datasets = requests.get('https://api.plateauview.mlit.go.jp/datacatalog/plateau-datasets').json()['datasets']
models = [{
  'name': dataset['name'],
  'code': dataset['ward_code'] or dataset['city_code'],
  'url': dataset['url']
} for dataset in datasets if dataset['type_en'] == 'bldg' and dataset['lod'] == '2' and dataset['texture']]
print('Processing {} models...'.format(len(models)))

with open('N03-23_230101.geojson') as res:
  data = json.load(res)
with open('plateau-models.geojson', 'w') as f:
  f.write('{"type":"FeatureCollection","features":[\n')
  for model in models:
    geometries = []
    for feature in data['features']:
      if feature['properties']['N03_007'] == model['code']:
        geometries.append(from_geojson(json.dumps(feature)))

    if len(geometries) == 0:
      print('{} ({}) is empty'.format(model['name'], model['code']))
      continue

    geometry = MultiPolygon(geometries).buffer(0.01).simplify(0.001)

    f.write('{')
    f.write('"type":"Feature",')
    f.write('"geometry":{},'.format(to_geojson(from_wkt(to_wkt(geometry, rounding_precision=7)))))
    f.write('"properties":{')
    f.write('"code":"{}",'.format(model['code']))
    f.write('"url":"{}"'.format(model['url']))
    f.write('}')
    f.write('}')
    if model != models[-1]:
      f.write(',')
    f.write('\n')
  f.write(']}\n')

生成された全国213市区町村の領域データはこんな感じです。オーバーラップしていますよね。

次はフロントエンド側です。Mapbox の Map を作成したら、事前に先ほどの GeoJson ファイルをデータソースとして、透明(fill-opacity が 0)な fill レイヤーを追加しておきます。

map.addLayer({
  id: 'plateau-model',
  type: 'fill',
  source: {
    type: 'geojson',
    data: MODEL_URL
  },
  paint: {
    'fill-opacity': 0
  }
});

透明なので地図上には何も表示されませんが、queryRenderedFeatures を使って画面内の任意の座標に存在する Feature を取得することが可能です。このメソッドは CPU の幾何計算ではなく、GPU ピッキングを行う手法のため、パフォーマンス上も有利です。次のようにすれば、move イベントが発生するたびに画面中央に存在する(複数の)Feature を取得して、市区町村コードと 3D Tiles URL を使った処理を進めることができます。

map.on('move', () => {
  const {width, height} = map.getContainer().getBoundingClientRect();
  const features = map.queryRenderedFeatures([width / 2, height / 2], {layers: ['plateau-model']});
  
  for (const feature of features) {
    const {code, url} = feature.properties;

    /* ... */
  }
});

あとは新たに領域が検出されたら新しい Tile3DLayer を追加し、画面外に外れた領域の Tile3DLayer を削除するようにするだけで、動的なデータのロード/アンロードは実装完了です。

テクスチャの軽量化

これで全国の都市モデルが表示できるようになったものの、場所によってはまだ非常に動作が重たいですし、フリーズしてしまうこともありました(私の MacBook Pro のメモリ 16GB なので・・)。

ブラウザの開発者ツールのプロファイルを見ても、大して JavaScript ヒープは消費しておらず、一方でシステムのメモリはいっぱいいっぱい。macOS のユニバーサルメモリのことを考えると、おそらく GPU がテクスチャデータ用に確保しているメモリだろうと想像がつきます。実際、テクスチャなしであれば PLATEAU のモデルは軽快に動作します。

そこで、テクスチャデータを圧縮できる場所はないだろうかと探していたところ、テクスチャ画像は 3D Tiles に含まれる GLTF データの一部として存在しており、Tile3DLayer の引数として渡ってくる Tile オブジェクトからアクセスできること、そしてロード直後は画像データの差し替えが可能なことがわかりました。

Mini Tokyo 3D の利用ケースであれば、そこまで精細なテクスチャ画像は不要なため、画像の高さ、幅ともに 1/4 にして、データサイズとしては約 1/16 になることを目論みました。

これに加えて featureTableBinary、featureTableJson、batchTableBinary、batchTableJson の各データもロード後は不要なので、参照を削除しておきます。

onTileLoad: ({content}) => {
  /* ... */
  content.featureTableBinary = null;
  content.featureTableJson = null;
  content.batchTableBinary = null;
  content.batchTableJson = null;

  for (const item of content.gltf.images || []) {
    const image = item.image;
    const resizeWidth = image.width / 4;
    const resizeHeight = image.height / 4;

    createImageBitmap(image, {resizeWidth, resizeHeight}).then(resizedImage => {
      item.image = resizedImage;
    });
  }
}

この最後のテクスチャの軽量化がかなり有効だったようです。全国どこに移動しても、スムーズに建築物モデルが表示できるようになりました!

ぜひお試しを

そこそこ軽くなった PLATEAU プラグインをお楽しみいただくには、https://minitokyo3d.com にアクセスし、画面右側の下から3つ目の「レイヤー設定」ボタンを押して、「PLATEAU」レイヤーを選択して有効にしてください(デフォルトでは表示オフになっています)。

Mini Tokyo 3D、PLATEAU Plugin for Mini Tokyo 3D は GitHub で公開されているので、チェックしてね!

Mini Tokyo 3D バージョン 3.5.0 リリース

Mini Tokyo 3D バージョン 3.5.0 がリリースされました (GitHub)。3.4.0 からの追加・修正機能を見ていきましょう。

新しい駅パネルの導入と列車発車標の追加

駅情報パネルをリニューアルしました。従来の駅出口情報に加えて、各路線の発車標(先発・次発の列車を表示)、経路検索を統合。すべての駅がクリックして選択できるようになりました。さらに、画面右上の経路検索ボタンはバージョン 2.4 以前の駅名検索ボタンに戻し、駅にすぐに飛べるようにしています。

スペイン語サポート

コミュニティーのコントリビューションにより、スペイン語に対応しました。

東海道貨物線と中央本線高尾〜大月間が開通

東海道貨物線経由の東京着および新宿発着の特急湘南の表示に対応しました。東戸塚駅を過ぎたところで羽沢線の長いトンネルに突入し、羽沢横浜国大駅を横目に既存の相鉄直通線に合流します。マニアも納得のルートを再現しています。

また、中央本線の高尾〜大月間が開通。小仏峠の下をくぐる高尾〜相模湖間は、首都圏でも屈指の長さの駅間距離9.5kmです。

地下駅出口情報の更新

地下駅出口のバリアフリー施設情報、利用可能時間の情報の追加を着々と進めています。今回は、埼玉高速鉄道、りんかい線、つくばエクスプレス、東葉高速鉄道、京浜急行、東京モノレール、JR 京葉線の各路線の駅に対応しました。また、東京メトロ東西線の南砂町駅の改良工事のために使用休止になった出口と新設された出口の情報も追加しています。

さらに、北総線の矢切付近を地下化、矢切駅に出口を追加しました。京王線の国領〜布田〜調布付近と京王八王子付近も地下化して、各駅の出口を追加しています。

ダイヤ改正対応

JR、りんかい線、東京メトロ、都営地下鉄、東武、西武、京王、小田急、東急、相鉄、横浜高速鉄道、埼玉高速鉄道、東葉高速鉄道、つくばエクスプレス、関東鉄道、流鉄、小湊鉄道、いすみ鉄道、金沢シーサイドライン、東京モノレール、千葉モノレールの2024年3月18日のダイヤ改正に対応。新京成線の2024年3月23日のダイヤ改正に対応。また、JR 京葉線、武蔵野線、内房線、外房線の2024年9月1日のダイヤ改正に対応しています。

2023年7月15日に登場した東武特急スペーシア X(東武 N100 系)は3月のダイヤ改正で1日6往復に増発。遅ればせながら Mini Tokyo 3D にも投入されました。

駅選択操作の拡張に伴うメソッドおよびイベントでの駅 ID サポート

バージョン 3.5.0 では、駅の選択操作の拡張に伴い、Map クラスの次のコンストラクタオプションで駅 ID がサポートされました。

プロパティ 説明
selection 駅 ID を指定すると駅を選択する

また、Map クラスの次のメソッドで駅 ID がサポートされました。

メソッド 説明
getSelection 選択された駅の ID の配列を返す
setSelection 駅 ID を指定すると駅を選択する

さらに、Mapクラスの次のイベントで駅 ID がサポートされました。

イベント 説明
deselection 駅の選択が解除された場合に発生
selection 駅が選択された場合に発生

詳細は API リファレンスをご覧ください。

ライブカメラの拡充

ライブカメラプラグインで表示される、沿線のライブカメラがさらに追加されました。

  • 新大久保ライブカメラ
  • 五反野ライブカメラ
  • 尾久ライブカメラ
  • 千住大橋ライブカメラ
  • 荻窪ライブカメラ
  • 有楽町ライブカメラ
  • 大船ライブカメラ
  • 武蔵小杉ライブカメラ2
  • 渋谷ライブカメラ2
  • 芝浦ふ頭ライブカメラ
花火大会情報の表示

花火プラグインがアップデートされました。データベースから動的にデータを取得し、当日の花火大会の情報が画面左側に表示されます。ボタンを押すと開催場所にジャンプします。2024年の夏からは首都圏の中規模以上の花火大会をすべて登録。ライブカメラとシンクロして、デジタルツイン上で花火大会を楽しむことができます。

Mini Tokyo 3D バージョン 3.4.0 リリース

Mini Tokyo 3D バージョン 3.4.0 がリリースされました (GitHub)。3.3.0 からの追加・修正機能を見ていきましょう。

実際の太陽の位置に合わせた光源の設定

建築物の光の反射はこれまで固定ライトを使っていましたが、太陽の位置に合わせた光の反射に変更しました。時間と共に、建築物の見え方が変化してリアリティが少し向上しています。太陽の天球上の位置は正確に計算しているので、季節によっても影のつき方が変わります!

遮蔽による光の減衰を考慮した建物の影の描写

建築物のシェーディング(陰影の付与)にアンビエントオクルージョン(遮蔽による光の減衰を考慮して影を描写する手法)を適用しました。左が適用前、右が適用後です。効果は大きく、かなり風景のリアリティが増しています。

路線のライン形状の改善

地下鉄路線の形状データを更新しました。線形がだいぶ改善され、無理のない形状になっています。これは OpenStreetMap のデータ品質が3年前から大きく向上した成果です。

地下駅出口情報の更新

地下駅出口のバリアフリー施設情報、利用可能時間の情報の追加を着々と進めています。今回は、東京メトロ有楽町線、半蔵門線、南北線、副都心線と、都営浅草線、三田線、新宿線、大江戸線の各路線の駅に対応しました。また、西新宿駅や虎ノ門ヒルズ駅、神谷町駅などの開発が進んでいるエリアに新設された出口も追加しています。

ダイヤ改正対応

りんかい線の 2023/3/18 ダイヤ改正、東京メトロ銀座線の 2023/4/29 ダイヤ改正に対応。また、流鉄流山線の 2023/7/1 ダイヤ改正に対応。さらに、都営浅草線、京急、京成、北総鉄道、芝山鉄道の 2023/11/25 ダイヤ改正に対応しています。

ライブカメラの拡充

ライブカメラプラグインで表示される、沿線のライブカメラがさらに追加されました。

WebGL 2.0 対応

デフォルトで WebGL 2.0 を使った描画を行うようになりました。基本的には描画品質や性能は変わらないと思いますが、開発者がレイヤーの追加を行う場合には WebGL 2.0 ベースの gl コンテキストやレンダラー、GLSL ES 3.0 などを利用することができるようになります。

Bluesky に画像をポストするボットのコード

一般公開が始まった Bluesky、ユーザー数が一気に増えたので自動でポストを行うボットのニーズも増えてきたかと思います。いずれ誰かがもっと体系的な情報を整備すると思いますが、とりあえずボットを動かすことができたので、2024年2月時点でのポストを行うコードを公開しておこうと思います。ほんとに簡単です。

Japan EQ Locator、World EQ Locator では地震が発生するたびに、テキスト、サイトの URL、地震のスクリーンショットの画像をポストしています。以下では、これらのデータはすでに準備ができているものとして、Bluesky へのポストの部分のみに注目します。なお、使用言語は Python です。

まず AT Protocol SDK atproto と画像ライブラリ Pillow をインストールしておきます。

pip install atproto Pillow

そして、コードの先頭でこれらのパッケージのクラスやモジュールをインポートします。なお、BytesIO は変換した画像のバッファとして使用します。

from atproto import Client, client_utils
from PIL import Image
from io import BytesIO

次に、AT Protocol クライアントを作成します。Client のコンストラクタの引数には base_url として 'https://bsky.social' を指定します。そして、自分の Bluesky ハンドル名とパスワードを使ってログインします。

client = Client(base_url='https://bsky.social')
client.login('<handle>.bsky.social', '<password>')

で、こちらがポストのテキストを用意する部分。text には投稿内容、url にはリンクが格納されている前提です。ポイントは、client_utils.TextBuilder を使ってテキストを組み立てている点です。X と異なり、Bluesky ではテキスト中の URL は自動ではリンクにならないため、client_utils.TextBuilder を使ってリンクや装飾などが付いたリッチテキストを組み立てる必要があります。

text = f'{year}年{month}月{day}日{hour}時{minute}分頃、' \
    f'{location}を震源とする地震がありました。' \
    f'震源の深さは{depth}、地震の規模は{magnitude}。'
url = 'https://nagix.github.io/japan-eq-locator/?' \
    + urllib.parse.urlencode(quake)

text_builder = client_utils.TextBuilder() \
    .text(text + '\n') \
    .link('nagix.github.io/japan-eq-loc...', url)

続いて画像を用意します。img_file に画像のパス名が入っている前提です。画像を未加工でポストするなら、ファイルをバイナリとして open して read() で返ってくる bytes オブジェクトを取得するだけて良いのですが、このケースでは読み込んだ PNG ファイルのサイズが Bluesky でポストできる画像のサイズの上限 1,000,000 バイトを超える恐れがあったため、Pillow を使って読み込み、RGB モードに変換(アルファ成分を除去)した上で JPEG としてバッファに書き出し、bytes オブジェクトを取得しています。

img_file = 'screenshot.png'

img = Image.open(img_file).convert('RGB')
with BytesIO() as f:
    img.save(f, format='JPEG')
    jpg = f.getvalue()

最後に、引数に組み立てたテキストデータ、画像データ、画像の代替文字列(ALT テキスト、ここではリンクを除いた本文を使用)、言語を指定して send_image() で送ります。

client.send_image(text_builder, jpg, text, langs=['ja'])

以上です!思ったより簡単ですね?もし詳しい SDK の解説を見たい方は、こちらのリファレンスが参考になると思います。

atproto.blue