GIS
foss4g
Cesium
Maplat
CesiumDay 25

Cesiumで古地図 - 3D Maplatの第一歩

Cesium Advent Calendarの大トリを務めることとなりました。
私の古地図アプリプロジェクトMaplatですが、いつかは3D化したいと思っていました。
3D化して古地図上に地形の起伏なども表示して、ウォークするできるようにすれば全く新しいユーザ体験になるし、大人のポケモンGOのようなコンテンツ(いやポケモンGOは大人もやってますけどね)ができれば、そのUIプラットフォームにもできるなと思っています。
それを実現するために、Cesiumとはいずれ格闘しなければと思っていました。たのですが、今回Cesium Advent Calendarが開催されたのでできるところまでやってみました!

やってみた成果がこちらにあります。
Cesiumで延岡古地図
まだ現在地を表示したり、地形の起伏を表示したり、通常地図と切り替えたりはできませんが、とりあえず古地図タイルを表示して俯瞰するところまではできました。
まずは第一歩、成功です!

苦労した点いくつかですが、

1) まずさすがに古地図をGlobe Viewで表示するのは無謀なので、Columbus View(鳥観図のように平面表示するモード)で表示することにしました。
ところが、CesiumのColumbus ViewはGoogle Maps APIやLeafletのように球面メルカトル図法ではなく、正距円筒図法(Plate Caree)なので、タイルに対して座標系が歪んでおり、最大ズームで古地図画像を表示すると、Y座標方向に縮められたり引き延ばされたりして見るに堪えません。
仕方なく、数ズーム分ずらしたズーム位置の、赤道付近でタイルを読み込むようにしました。
経緯度(0,0)の点をタイル原点にして、そこから東半球、南半球の方向に表示しています。
本当は、少しでも歪みを小さくするために、緯度方向は赤道を挟む形で座標系を設定したかったのですが、なぜか座標位置合わせがどうしてもうまくいかなかったので断念しました。
この部分のコード実装は、以下のような感じになっています。

Cesiumでの古地図タイル読み込み
var widget = new Cesium.CesiumWidget('cesiumContainer', {
    sceneMode : Cesium.SceneMode.COLUMBUS_VIEW,
    imageryProvider: new Cesium.UrlTemplateImageryProvider({
        url: "https://t.tilemap.jp/maplat/tiles/1952_nobeoka/{sz}/{sx}/{sy}.jpg",
        customTags: {
            sz: function(imageryProvider, x, y, level) {
                return level - adjustZoom;
            },
            sx: function(imageryProvider, x, y, level) {
                return x - Math.pow(2, level - 1);
            },
            sy: function(imageryProvider, x, y, level) {
                return y - Math.pow(2, level - 1);
            }
        }
    })
});

2) 地図の中心付近にカメラを持ってきたかったのですが、そのためには地図のピクセル座標と、Cesium上の経緯度の変換ロジックが必要になります。
これはそれほど大変でもなかったのですが、一応これも実装を載せます。
(なお、これはCesiumを全球ビューアとしてみた場合の経緯度座標との変換であって、古地図中の、この例では延岡の経緯度との変換ではありません。)

Cesiumでの地図ピクセル座標とCesiumの元経緯度の変換
var adjustZoom = 4;
var width = 10000;
var height = 6915;
var MAX_MERC = 20037508.342789244;

function pixelXyToMercatorXy(pxy) {
    var px = pxy[0];
    var py = pxy[1];
    var maxWh = width > height ? width : height;
    var maxPixel = Math.pow(2, Math.ceil(Math.log(maxWh)/Math.log(2)));
    var maxMerc = MAX_MERC * 2 / Math.pow(2, adjustZoom);
    var mercPerPixel = maxMerc / maxPixel;

    return [px * mercPerPixel, -1.0 * py * mercPerPixel];
}

function mercatorXyToLngLat(mxy) {
    var mx = mxy[0];
    var my = mxy[1];
    var lng = mx * 180 / MAX_MERC;
    var lat = 360 / Math.PI * Math.atan(Math.exp(my * Math.PI / MAX_MERC)) - 90;
    return [lng, lat];
}

var ll = mercatorXyToLngLat(pixelXyToMercatorXy([width / 2, height / 2]));

widget.camera.flyTo({
    destination: Cesium.Cartesian3.fromDegrees(ll[0], ll[1], 400000.0),
    orientation: {
        heading: Cesium.Math.toRadians(0.0),
        pitch: Cesium.Math.toRadians(-40.0),
        roll: 0.0
    }
});

3) 古地図の場合、地図の右端/下端のタイルで、256px四方になっていないタイルが存在するのですが、これをきちんと読み込めるようにする対応も必要でした。
これは手法自体はOpenLayers等でもやった経験があって、ダミーのimg要素でタイルを読み込んだ後、サイズが256px四方でなければ256px四方の透明Canvas上に描画してやって、そのCanvasのdata-URLを取得してもう一遍imgに読ませてやる、という形で実現可能なのですが、Cesiumがタイルの読み込み処理をしている場所の特定と、Cesiumの中身をいじらないといけないので、変更後自分でCesiumのminify等パッケージングしないといけないのが大変でした。
下記に、変更したCesium内のファイルと変更内容を載せます。

Source/Core/loadImage.js
define([
        '../ThirdParty/when',
        './Check',
        './defaultValue',
        './defined',
        './isCrossOriginUrl',
        './isDataUri',
        './Request',
        './RequestScheduler',
        './TrustedServers'
    ], function(
        when,
        Check,
        defaultValue,
        defined,
        isCrossOriginUrl,
        isDataUri,
        Request,
        RequestScheduler,
        TrustedServers) {
    'use strict';

    var transPng = 'data:image/png;base64,'+
        'iVBORw0KGgoAAAANSUhEUgAAAQAAAAEACAMAAABrrFhUAAAAB3RJTUUH3QgIBToaSbAjlwAAABd0'+
        'RVh0U29mdHdhcmUAR0xEUE5HIHZlciAzLjRxhaThAAAACHRwTkdHTEQzAAAAAEqAKR8AAAAEZ0FN'+
        'QQAAsY8L/GEFAAAAA1BMVEX///+nxBvIAAAAAXRSTlMAQObYZgAAAFRJREFUeNrtwQEBAAAAgJD+'+
        'r+4ICgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'+
        'AAAAAAAAAAAAABgBDwABHHIJwwAAAABJRU5ErkJggg==';
    var tileSize = 256;
    var canvBase = '<canvas width="' + tileSize + '" height="' + tileSize + '" src="' + transPng + '"></canvas>';

    /**
     * Asynchronously loads the given image URL.  Returns a promise that will resolve to
     * an {@link Image} once loaded, or reject if the image failed to load.
     *
     * @exports loadImage
     *
     * @param {String} url The source URL of the image.
     * @param {Boolean} [allowCrossOrigin=true] Whether to request the image using Cross-Origin
     *        Resource Sharing (CORS).  CORS is only actually used if the image URL is actually cross-origin.
     *        Data URIs are never requested using CORS.
     * @param {Request} [request] The request object. Intended for internal use only.
     * @returns {Promise.<Image>|undefined} a promise that will resolve to the requested data when loaded. Returns undefined if <code>request.throttle</code> is true and the request does not have high enough priority.
     *
     *
     * @example
     * // load a single image asynchronously
     * Cesium.loadImage('some/image/url.png').then(function(image) {
     *     // use the loaded image
     * }).otherwise(function(error) {
     *     // an error occurred
     * });
     *
     * // load several images in parallel
     * when.all([loadImage('image1.png'), loadImage('image2.png')]).then(function(images) {
     *     // images is an array containing all the loaded images
     * });
     *
     * @see {@link http://www.w3.org/TR/cors/|Cross-Origin Resource Sharing}
     * @see {@link http://wiki.commonjs.org/wiki/Promises/A|CommonJS Promises/A}
     */
    function loadImage(url, allowCrossOrigin, request) {
        //>>includeStart('debug', pragmas.debug);
        Check.defined('url', url);
        //>>includeEnd('debug');

        allowCrossOrigin = defaultValue(allowCrossOrigin, true);

        request = defined(request) ? request : new Request();
        request.url = url;
        request.requestFunction = function() {
            var crossOrigin;

            // data URIs can't have allowCrossOrigin set.
            if (isDataUri(url) || !allowCrossOrigin) {
                crossOrigin = false;
            } else {
                crossOrigin = isCrossOriginUrl(url);
            }

            var deferred = when.defer();

            loadImage.createImage(url, crossOrigin, deferred);

            return deferred.promise;
        };

        return RequestScheduler.request(request);
    }

    // This is broken out into a separate function so that it can be mocked for testing purposes.
    loadImage.createImage = function(url, crossOrigin, deferred) {
        var image = new Image();

        image.onload = function() {
            if (image.width != tileSize || image.height != tileSize) {
                var tmp = document.createElement('div');
                tmp.innerHTML = canvBase;
                var tCanv = tmp.childNodes[0];
                var ctx = tCanv.getContext('2d');
                ctx.drawImage(image, 0, 0);
                var dataUrl = tCanv.toDataURL();
                image.src = dataUrl;
            } else {
                deferred.resolve(image);
            }
        };

        image.onerror = function(e) {
            deferred.reject(e);
        };

        if (crossOrigin) {
            if (TrustedServers.contains(url)) {
                image.crossOrigin = 'use-credentials';
            } else {
                image.crossOrigin = '';
            }
        }

        image.src = url;
    };

    loadImage.defaultCreateImage = loadImage.createImage;

    return loadImage;
});

また、改造済みのCesiumリポジトリがこちらにあります。

これでひとまず、Cesiumの上で立体的に古地図を表示し、グリグリ動かすことができるようになりました。
今後、

  • 現在地経緯度と古地図上経緯度の変換方法定義(これはほぼMaplatの手法が使える)
  • 高さの概念をどうするか(元々ズームをズラして定義しているので、単にそのままの高さ指定をするわけにはいかない。また、地上の古地図のMeterPerPixelの値との整合もトライ&エラーの必要あり)
  • 標高データの古地図座標化、タイル化して標高表示の導入

等等、見て楽しいコンテンツ化するにはいくつものハードルがありますが、とりあえず第一歩は踏み出せたかなと思います。