プロジェクト

全般

プロフィール

JavaFXとベクター地図表示

目的

電子地図データを読み込み、画面に地図を描画するプログラムを作成します。

電子地図データには大きくベクター形式とラスター形式がありますが、今回はベクター形式のデータを対象とします。ベクター形式のデータとしては、海岸線、国や州・県・市などの行政界、河川、湖沼、道路、鉄道、などがあります。ベクター形式には、点(ポイント)、複数の頂点を線で結んだ折れ線(ポリライン)、複数の頂点を線で結び閉じた図形(ポリゴン)があります。各点は、地理座標系(緯度経度)で表現されるものが主です。緯度経度は地球表面上の位置を表しますが、地球表面は紙や画面のような平面ではないので、投影によって地球表面上の点を平面上の点にマッピングします。投影法には、目的・用途によっていくつも種類があります。投影により距離、方位、面積に必ず歪みが生じ、投影法の種類により変わります。

ベクター形式の電子地図データファイルは、GIS1の分野で広く使われるシェープファイル形式を扱います。
シェープファイルから読み出したベクター形式のデータの緯度経度座標を、投影法により平面XY座標(単位:メートル)に変換し、平面XY座標をさらにアフィン変換で画面XY座標(単位:ピクセル)に変換して表示します。

1 GIS:Geographica Information System の略で地理情報システムのこと

使用するライブラリ

シェープファイルから地図データの読み込み

Java ESRI Shapefile Reader を使います。
http://sourceforge.net/projects/javashapefilere/

アーカイブファイルを上述URLから入手し、中に含まれるshapefilereader-1.0.jarを使用します。

地図の地理座標系から平面座標系への投影変換

Proj4J を使います。
Proj4J に入手・ビルド等を記述しています。

平面座標系から画面座標系への変換

投影された地図データは、平面XY座標系で単位はメートルです。これを、画面に描画するためには画面XY座標系で単位はピクセルにします。単位の他には、平面XY座標系と画面XY座標系ではY軸の正の向きが反対になります。画面XY座標系はY軸が下向きを正とします。

この変換は、JavaFX APIのAffineで定義します。

画面への地図表示

JavaFXを用います。ベクターの描画にはCanvasクラスを使用します。

画面表示イメージ

screenshot-1.png

地図を表示するCanvasと、表示している縮尺を示すLabelと、地図データファイルを読み込むButtonを配置した画面です。
Canvas上でマウスのホイール操作をすると地図の拡大・縮小が行われます。地図を拡大・縮小すると縮尺が変わるので、縮尺のLabelに表示する値も変更します。
Canvas上でマウスのドラッグ操作をすると地図のスクロールが行われます。
ウィンドウのリサイズをすると、Canvasがウィンドウの大きさに合わせてリサイズします。

ソースコード

本Redmineのリポジトリ上、次に格納しています。
source:learn/java/javafx/HelloMap

以下に主要部を抜粋、説明します。

シェープファイルの読み込み

ファイルの指定(JavaFX ファイル選択ダイアログ)

    FileChooser chooser = new FileChooser();
    chooser.setTitle("Select ESRI Shapefile");
    chooser.setInitialDirectory(Paths.get(System.getProperty("user.dir"), "mapdata").toFile());
    chooser.getExtensionFilters().add(new FileChooser.ExtensionFilter("Shapefile", "*.shp"));
    File selected = chooser.showOpenDialog(map.getScene().getWindow());

JavaFXのファイル選択ダイアログでシェープファイルを指定します。
プログラム実行ディレクトリ(カレントディレクトリ)をシステムプロパティから取得しその下のmapdataをデフォルトの場所としてファイル選択ダイアログを表示させます。
シェープファイルは拡張子.shpなので、限定する拡張子にそれを指定します。
ファイル選択ダイアログはトップレベルウィンドウ(stage)を引数に指定しますが、通常コントローラではstageの参照を保持していません。そこで、コントローラが保持するコントロールからstageを取り出して指定しています。

ファイルの読み込み(Java ESRI shapefile readerライブラリ)

    ValidationPreferences preferences;
    preferences = new ValidationPreferences();
    preferences.setAllowUnlimitedNumberOfPointsPerShape(true);

シェープファイルに多数(1万以上)のポイントがあると読み込みエラーとなるので、制限を解除します。
    try (InputStream inStream = new BufferedInputStream(new FileInputStream(shapeFile))) {
        ShapeFileReader reader = new ShapeFileReader(inStream, preferences);
        PolylineShape shape = (PolylineShape) reader.next();
        while (shape != null) {
            for (int i = 0; i < shape.getNumberOfParts(); i++) {
                MapPolyline mapPolyline = new MapPolyline(projection);
                mapPolyline.setGcsPolyline(shape, i);
                shapes.add(mapPolyline);
            }
            shape = (PolylineShape) reader.next();
        }
    } catch (IOException ex) {
        throw new UncheckedIOException(ex);
    }

シェープファイルのInputStreamを作成し、ShapeFileReaderを生成します。
ShapeFileReaderはイテレーターとなっているので、シェープを順次読み込みます。
シェープファイルは今回はポリラインに決め打ちしています(今後ポリゴン、ポイントに対応していきたい)。
ポリラインはマルチパート(1つのレコードに連続しない複数のポリラインが格納)の可能性があるので、その対応をしています。getNumberOfPartsが2以上の場合はマルチパートです。
    public void setGcsPolyline(PolylineShape gcsShape, int part) {
        this.gcsShape = gcsShape;
        partsIndex = part;
        int numPoints = gcsShape.getPointsOfPart(part).length;
        xPoints = new double[numPoints];
        yPoints = new double[numPoints];
        project();
    }

JavaFXのPolylineは、各頂点のX座標の配列、Y座標の配列を別々に保持するデータ構造なので、それに合わせて投影後の座標を格納する配列を用意しています。

地理座標から投影座標への変換

このプログラムでは、指定した緯度経度を中心とする正距方位投影を行います。

1つの点について地理座標(緯度経度)から投影座標(XY)へ変換(Proj4Jライブラリ)

    private Function<PointData, Point2D> createProjection(double lon, double lat) {
        CoordinateReferenceSystem crsWgs84 = crsFactory.createFromName("EPSG:4326");
        CoordinateReferenceSystem crsAzEq = crsFactory.createFromParameters(
                "Azimuthal_Equidistant",
                String.format("+proj=aeqd +lat_0=%f +lon_0=%f +x_0=0 +y_0=0 +ellps=WGS84 +datum=WGS84 +units=m +no_defs", lat, lon));
        CoordinateTransform projection = coordinateTransformFactory.createTransform(crsWgs84, crsAzEq);
        return point -> {
            ProjCoordinate gcsPoint = new ProjCoordinate(point.getX(), point.getY());
            ProjCoordinate pcsPoint = new ProjCoordinate();
            pcsPoint = projection.transform(gcsPoint, pcsPoint);
            return new Point2D(pcsPoint.x, pcsPoint.y);
        };
    }

引数で投影の基準座標(緯度、経度)を取ります。
地理座標(緯度経度)をPointData型で与え、投影座標(XY)をPoint2D型で返却する関数インタフェースFunctionのインスタンスを生成します。
地理座標系はWGS1984を定義します。WGS1984を示すEPSGコードを指定しています。
投影座標系は正距方位図法(Azimuthal_Equidistant)を各種パラメータで指定しています。
地理座標系と投影座標系の定義から、投影変換を生成します。
関数インタフェースの実装クラス(無名クラス)を生成してreturnします。

1つのポリラインについて地理座標から投影座標へ変換

    public void project() {
        PointData[] gcsPoints = gcsShape.getPointsOfPart(partsIndex);
        int numPoints = gcsPoints.length;
        for (int i = 0; i < numPoints; i++) {
            Point2D pcsPoint = project(gcsPoints[i]);
            xPoints[i] = pcsPoint.getX();
            yPoints[i] = pcsPoint.getY();
        }
    }

緯度経度の点列を投影座標のX座標列、Y座標列に変換していきます。

投影座標から画面座標への変換

JavaFXのAffine生成(scale)

scaleProperty.set(1 / mapToScale(10_000_000));
transform = new Affine(scaleProperty.get(), 0f, 0f, 0f, -scaleProperty.get(), 0f);

表示したい地図の縮尺に相当するAffineのscale値(拡大縮小の比率)を計算します。ここでは初期表示は1千万分の1の縮尺(実際の1万kmが地図上で1mに相当する)としています。投影座標と画面座標でY軸の正負が逆なので、Y座標の拡大変換では-1をかけています。
    private static final double DOT_PITCH_METER = 0.247 / 1_000;
    :
    double mapToScale(double reduce) {
        return reduce * DOT_PITCH_METER;
    }

典型的なディスプレイは96DPIとして、1ピクセルの大きさをメートルで表した定数を定義します。
地図の縮尺をピクセルで表現した縮尺にします。

JavaFXのAffine生成(translate)

    transform.setToTransform(
            scaleProperty.get(), 0f, translate.getX(),
            0f, -scaleProperty.get(), translate.getY());

ドラッグ操作で地図をスクロールしたときに、平行移動(translate)を加えたAffineを設定します。

描画

JavaFXのCanvasへの描画

    private void drawMap() {
        GraphicsContext gc = map.getGraphicsContext2D();
        clearMap();
        gc.setTransform(transform);
        gc.setStroke(Color.BROWN);
        gc.setLineWidth(1);
        polylines.stream().forEach(shape
                -> gc.strokePolyline(shape.getxPoints(), shape.getyPoints(), shape.getNumPoints())
        );
    }

線の色、幅は固定でひたすらポリラインをCanvasのGraphicsContextにstrokePolylineで描いています。

JavaFXのCanvasのクリア

    private static final Affine IDENTITY_TRANSFORM = new Affine();
    :
    private void clearMap() {
        GraphicsContext gc = map.getGraphicsContext2D();
        gc.setTransform(IDENTITY_TRANSFORM);
        gc.setFill(Color.DODGERBLUE);
        gc.fillRect(0f, 0f, map.getWidth(), map.getHeight());
    }

Canvasの表示範囲を背景色で塗りつぶします。
表示範囲は、Affineの恒等変換を設定すればCanvasの大きさとなるのでそこをfillRectで矩形塗りつぶしします。

マウス操作で拡大縮小と移動

マウスホイール操作で表示の拡大縮小

    map.setOnScroll(ev -> {
        scaleProperty.set((ev.getDeltaY() >= 0) ? scaleProperty.get() * SCALE_RATE : scaleProperty.get() / SCALE_RATE);
        transform.setToTransform(scaleProperty.get(), 0f, translate.getX(), 0f, -scaleProperty.get(), translate.getY());
        drawMap();
    });

CanvasにsetOnScrollでホイール操作のイベントハンドラを設定します。
スクロールの方向をイベントの引数のY方向のスクロール量の正負で判定し、拡大または縮小を行います。ホイール量は使用せず一回のイベントで固定の倍率で拡大または縮小します。

マウスドラッグ操作で表示の移動

    map.setOnMousePressed(ev -> {
        dragStartPoint = new Point2D(ev.getSceneX(), ev.getSceneY());
        translateAtDragStart = translate;
    });
    map.setOnMouseDragged(ev -> {
        Point2D dragPoint = new Point2D(ev.getSceneX(), ev.getSceneY());
        translate = translateAtDragStart.add(dragPoint.subtract(dragStartPoint));
        transform.setToTransform(scaleProperty.get(), 0f, translate.getX(), 0f, -scaleProperty.get(), translate.getY());
        drawMap();
    });

マウスのドラッグ操作は、マウスのボタン押下イベントで開始し、定期的にマウスのドラッグイベントが発生するのを捉まえます。
マウス押下イベントで、マウス位置の画面座標(Canvasの左上を0とした画素)を記憶します。
マウスドラッグイベントで、マウス位置の画面座標を取得し、ドラッグ開始位置からの相対画素を計算してAffineを再設定し、地図を描画します。

地図データ

シェープファイル形式のベクター地図データを用意します。
今回は、1千万分の1、5千万分の1、1億1千万分の1の縮尺でベクターおよびラスター地図データをパブリックドメインとして提供しているNatural Earthのデータを入手し使用します。
http://www.naturalearthdata.com/

日本近海の海岸線データ

Natural Earthの海岸線(Coastline) 1千万分の1(10m)を入手します。
http://www.naturalearthdata.com/downloads/10m-physical-vectors/

上述URLから[Download coastline]をクリックするとzipアーカイブファイルがダウンロードできます。

ここから別なツール(GIS)で日本付近を切り出しました。結果を次に置いています。
source:learn/java/javafx/HelloMap/mapdata/fareast_coastline

発展・関連

JJUG CCC 2016 Fallで「世界は四角ではない~JavaFXで地図を描く」セッション

2016年12月3日(土)に開催されたJJUG CCC 2016 Fallにおいて「2015年4月11日(土)に開催されたJJUG CCC 2015 Springにおいて「JavaFXグラフィックスとアニメーション入門」というセッションでしゃべってきました。はてな日記に当日とその後の顛末を書きました。
http://d.hatena.ne.jp/torutk/20161228/p1

2018年補足

2018年9月時点で気づきましたが、グーグルマップで地図を縮小していくと、四角い地図(メルカトル図法)ではなく、球体に近い地図(正射図法?)で表示されるようになっていました。

クリップボードから画像を追加 (サイズの上限: 1 GB)