河本の実験室

河本が作ったものを紹介するブログです。こっち(https://kawalabo.blogspot.com/)から移転してきました。ポートフォリオ: http://俺.jp

Plateauを使って「富士山🗻が見える場所マップ」を作ろう

急に街歩いてて「富士山見てぇ!」となることありませんか?僕はしょっちゅうあります。そんな時のために、富士山が見える場所を速やかに探せる地図を作ってみました:

wherecaniseefuji.web.app

f:id:kenkawakenkenke:20210331200619p:plain

本稿は「Plateauのデータで遊んでみたい!」「GISデータを使ってグリグリ動かせる3D地図を作ってみたい!」という方のために、この地図の作り方をざっくり解説します。ものすごく長いので、自分が興味のある部分だけ読んだらいいと思います。

全体の流れ

「富士山が見える建物を探す地図」作りはこんな流れで行います。みんなが富士山地図を作りたいわけじゃないと思うので、自分が実際につくりたい用途に合わせて適当に読み替えてください。

  • データの読み込み:ダウンロードしたPlateauデータを自分のプログラムで好きに扱えるようにしょう。今回はJavaで扱う場合を解説します。
  • データの解析:建物データを分析し、富士山が見える建物を拾い出しましょう。
  • データの出力:可視化ページで表示できるように、データの出力を行いましょう。今回はGeoJSON形式(3D地図のため)と2Dタイル形式(2D地図のため)を出力する方法を解説します。
  • 可視化ページの作成:みんなが遊べるように、ブラウザで動く可視化ページを作りましょう。今回はReact + leaflet + OSMBuildingsを使いました。

扱うデータ

最近話題になっているPlateauを使ってみました。国土交通省がリリースした、日本各都市の正確・精細な建物データです。Twitterで検索すると色々な方が美麗な可視化をしているのを見つけられると思いますが、本稿ではあまり美しい3DCGは出てきませんのでそこは期待しないでください。

まずはデータをダウンロードしましょう。Plateauの意識高い感じのトップページから見つけるのは困難なのですが、 ググるダウンロードページが出てきます。何種類かデータフォーマットがありますが、プログラムからデータ解析したい場合は(世の中にあるライブラリ的に)CityGMLが良いと思います。

f:id:kenkawakenkenke:20210331182703p:plain

東京が14ファイルに分かれてzipされているので、全部ダウンロードしましょう。

CityGMLフォルダの中身

色んなzipがありますが、今回使うのは「bldg.zip」(建物データ)と「dem.zip」(地面の標高データ)との2つです。

データを読み込む

自分のプログラムで建物データを扱える環境を整えましょう。使いたい言語、環境によって色々方法が異なると思うので、「[言語] + CityGML」でググりましょう。筆者はJavaが好きなので、citygml4jというライブラリを使いました。作者が用意してくれているサンプルプログラムSimpleReader.javaを使えば難なく建物が読み込めると思います。

建物データ

まずは建物データを読み込んでみましょう。今回の「富士山が見える建物」のためにはざっくりとした形状が分かればいいので、「lod0roofedge」(一番荒い3Dデータ)しか使いません。これは、「高さ」と「外周の二次元形状」だけで建物を表す、最も荒い形式です。

<?xml version="1.0" encoding="UTF-8"?>
<core:CityModel...>
  ...
  <core:cityObjectMember>
    <bldg:Building gml:id="BLD_ec49bb85-efc6-4bd4-acb2-c3f230b28e9c">
      <!-- 地面からの高さなので注意 -->
      <bldg:measuredHeight uom="m">4.1</bldg:measuredHeight>
        <bldg:lod0RoofEdge>
          <gml:MultiSurface>
            <gml:surfaceMember>
              <gml:Polygon>
                <gml:exterior>
                  <gml:LinearRing>
                    <!-- 屋根の外周の座標値 -->
                    <gml:posList>
                      35.56646362724824 139.7668571400079 5.8260000000000005 35.56647781006162 139.76683164441192 5.8260000000000005 ...

f:id:kenkawakenkenke:20210331202107p:plain
外周と高さ情報しか扱わないので、東京タワーもこんな形になってしまいます。が、今回の分析のためにはこれで充分としました。
 

地形データ

同じく、demファイルも読み込んでみましょう。こちらは標高を表す三角形の羅列です:

<?xml version="1.0" encoding="UTF-8"?>
<core:CityModel...>
  <core:cityObjectMember>
    <dem:ReliefFeature gml:id="RLF_4a7e5bc5-ad22-4522-91d2-b6bc0677b088">
      <dem:lod>1</dem:lod>
      <dem:reliefComponent>
      <dem:TINRelief gml:id="DEM_4a7e5bc5-ad22-4522-91d2-b6bc0677b088">
        <dem:tin>
          <gml:TriangulatedSurface srsName="http://www.opengis.net/def/crs/EPSG/0/6697" srsDimension="3">
            <gml:trianglePatches>
              <gml:Triangle>
                <gml:exterior>
                  <gml:LinearRing>
                    <gml:posList>
                      35.53127756660067 139.7561542106938 2.9695 35.53127785139326 139.7566335063924 2.9885 35.53149395745237 139.75624222099665 2.9645 35.53127756660067 139.7561542106938 2.9695

f:id:kenkawakenkenke:20210331202442p:plain
例えば川を見るとこんな感じ

なぜ地形データも必要かというと、前述の通り建物データには「地面から屋根までの高さ」しか入っていないからです。建物の相対位置を正確に知るためには、更に地面の標高を足し合わせる必要があるので、地形データも取得しました。

富士山が見える建物を全部探す

さて、建物と地形のデータをプログラムで扱えるようになったらシコシコと三角関数等とかを使いながら建物間の遮蔽関係を計算していきます。 この辺は作りたいものに依りすぎるので、参考程度にざっくりとだけ説明します:

まず建物毎に「絶対」高さを計算します。これは各建物が立っている場所の標高(地形データより)と、建物の高さを足し合わせたものを各建物について計算したものです:

f:id:kenkawakenkenke:20210331210446p:plain
標高と・・・
f:id:kenkawakenkenke:20210331210544p:plain
建物の地面からの高さを足し合わせると・・・
f:id:kenkawakenkenke:20210331210557p:plain
建物の屋根の標高が計算できます。

次に、各建物について「富士山が見えるかどうか」を計算していきます。といってもなにか難しいことするわけではなく、愚直に「建物Aの屋上から富士山の五合目を見た時に建物Bが遮蔽するか否か」を総当り的に計算するだけです。こちらのアニメーションでは、ある建物(黒)から富士山を見た時に邪魔になる建物を赤く描画しています: f:id:kenkawakenkenke:20210331205901g:plain

実際に総当りすると計算に数週間かかってしまうので、色々と枝刈りをして一時間半ぐらいに落とします。頑張りましょう。 多分これ、レイトレのアルゴリズムとか使えば一瞬で計算できたりすると思います。が、一回計算するだけなので、一時間半待ちました。

その結果、「富士山が屋上から見える建物群」が計算できました: f:id:kenkawakenkenke:20210331211125p:plain

結果を使いやすい形に出力する

後述するように、今回はLeafletOSMBuildingsを使ってデータの可視化を行います。 Leafletのためには2D画像のXYZタイル、OSMBuildingsもXYZタイル化されたGeoJSON形式で出力します。

XYZタイルってなに?

国土地理院の解説がわかりやすいのでそっちを読みましょう。 要は:

  • [zoomLevel]-[x]-[y].pngのように同じ大きさ(256x256ピクセル)に分割された(メルカトル図法の)画像を事前に用意しておく
  • zoomLevel=0の時には全世界を一つの画像(つまり 0_0_0.png)で地球を表す。
  • zoomLevelが一つあがるたびに倍の詳細度になる。(つまりzoomLevel=1では 1-0-0.png, 1-0-1.png, 1-1-0.png, 1-1-1.pngの4枚の画像で地球を表す)。つまり一般的には 0 <= x,y < (1<<zoomLevel)

Leaflet用の2D画像を用意する

後述するLeafletは2D地図をブラウザで簡単に表示させてくれるライブラリです。前述したXYZタイル形式で画像を用意しておけば、至極簡単に描画してくれます。まずは画像だけ出力しておきましょう(雰囲気だけ):

// [0~1]のピクセル座標値を緯度[-90~90]に変換する
// [https://wiki.openstreetmap.org/wiki/Slippy_map_tilenames]
public static double normalizedXToLongitude(double x) {
  return (x - 0.5)*360;
}
// [0~1)のピクセル座標値を経度[-180~180)に変換する
// [https://wiki.openstreetmap.org/wiki/Slippy_map_tilenames]
public static double normalizedYToLatitude(double y){
  return 2*Math.toDegrees(Math.atan(Math.exp(2*Math.PI*(0.5-y)))-Math.PI/4)
}

for(int zoomLevel = 0; zoomLevel <= 18; zoomLevel++) {
  // さっき説明したやつ。ズームレベルが1つあがるたびに詳細度が倍になる。
  int tileSize = 1 << zoomLevel;
  for(int tileY = 0; tileY < tileSize; tileY++) {
    double tileWest = normalizedYToLatitude( tileY / (double) tileSize);
    double tileEast = normalizedYToLatitude( (tileY+1) / (double) tileSize);
    for(int tileX = 0; tileX < tileSize; tileX++) {
      double tileNorth = normalizedXToLongitude( tileX / (double) tileSize);
      double tileSouth = normalizedXToLongitude( (tileX+1) / (double) tileSize);

      // (どうにかして)タイル内に引っかかる建物をとってくる
      List<Building> buildingsInTile = fetch(tileNorth, tileWest, tileSouth, tileEast);
      // (どうにかして)描く
      BufferedImage img = draw(buildingsInTile);
      // 書き出す
      ImageIO.write(new File(String.format("tiles2d/%d_%d_%d.png", zoomLevel, tileX, tileY), "png", img);
    }
  }
}

一つ一つのタイルはこのように、地球のどこかを切り取った小さな画像になります: f:id:kenkawakenkenke:20210331225805p:plain

作った画像たちをCloud Storageにでもあげておきましょう:

gsutil -m cp -R tiles2d gs://[your bucket]

こうすれば、こんなURLでタイルをとってこれるようにまります: https://storage.googleapis.com/[your bucket]/tiles2d/{z}{x}{y}.png

OSMBuildings用のGeoJSONを書き出す

後述するOSMBuildingsは3D地図を簡単に表示させてくれるライブラリです。Leafletと同じように、XYZタイル形式で用意した3Dモデルのタイルを描画してくれます。こちらも同じように出力しておきましょう:

public Object renderBuilding(Building building) {
  List coordinates = building.lod0RoofEdge.stream()
    // 何故かlongitude, latitudeの順番なので注意
    .map(p -> ImmutableList.of(p.coord.longitude, p.coord.latitude))
    .collect(toImmutableList());
  String color = building.canSeeFuji ? "#ff0000" : "#555555";
  return ImmutableMap.builder()
    .put("id", building.buildingID)
    .put("type", "Feature")
    .put("properties", ImmutableMap.of(
      "color", color,
      "roofColor", color,
      "height",building.measuredHeight))
    .put("geometry",
      ImmutableMap.of(
        "type", "Polygon",
        "coordinates", ImmutableList.of(coordinates)))
    .build();
}

public Object renderBuildings(List<Building> buildings) {
  return ImmutableMap.of(
    "type", "FeatureCollection",
    "features", buildings.stream().map(::renderBuilding).collect(toImmutableList())
  );
}

// ... for文の定義まで上に同じ
// (どうにかして)タイル内に引っかかる建物をとってくる
List<Building> buildingsInTile
  = fetch(tileNorth, tileWest, tileSouth, tileEast);
// (どうにかして)描く
Map renderedBuildings
  = renderBuildings(buildingsInTile);
// 書き出す
String json
  = new Gson().toJson(renderedBuildings);
File outFile = new File(String.format("tiles2d/%d_%d_%d.json", zoomLevel, tileX, tileY);
exportFile(outFile, json);

これも上と同じように、Cloud Storageにでもあげておきましょう。

可視化ページの作成

富士山が見える建物マップではLeafletOSMBuildingsを使って地図を描画しています。

Leafletで2D地図を描画する

描画自体はreact-leafletを使えばとても簡単です。

まず説明書の通り、index.htmlに以下の文を足しておきます:

<link
  rel="stylesheet"
  href="https://unpkg.com/leaflet@1.7.1/dist/leaflet.css"
  integrity="sha512-xodZBNTC5n17Xt2atTPuE1HxjVMSvLVW9ocqUKLsCC5CXdbqCmblAshOMAS6/keqq/sMZMZ19scR4PsZChSR7A=="
  crossorigin=""
/>

そしてページの好きなところにMapContainerのcomponentを足しましょう:

import { MapContainer, TileLayer } from 'react-leaflet'

<MapContainer
  center={initialCenter}
  zoom={initialZoom}
  scrollWheelZoom={true}
  style={{ height: "100%", width: "100%" }}
>
  <-- 下にOSMの地図も描画しとく -->
  <TileLayer
    attribution='&copy; <a href="http://osm.org/copyright">OpenStreetMap</a> contributors'
    url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
    opacity={0.3}
  />
  <-- さっき作ったやつ! -->
  <TileLayer
    url="https://storage.googleapis.com/[your bucket]/tiles2d/{z}_{x}_{y}.png"
  />
</MapContainer >

これが上手く動くと、このようにOSMの上に自分で描画した建物が表示されるはずです: f:id:kenkawakenkenke:20210331222328p:plain

はまる点としては、僕はNext.jsを使おうとして失敗しました。Leafletを使うにはcomponentをdynamic importする必要があるのですが、しかしdynamic importを使おうとすると、componentが一々再描画されてフリッカーしたりstateがリセットされる現象が解決できず、結局Reactを使いました。

OSMBuildingsで3D地図を描画する

こちらはreact用の優しいcomponentは用意されていません。なのでrefを使って自前で用意したdivにbindしましょう。 まずindex.htmlのどこかでOSMBuildingsのスクリプトを読み込んでおきます。

<script src="https://cdn.osmbuildings.org/4.0.0/OSMBuildings.js"></script>

そしてdivを作ってOSMBuildingsをbindしましょう:

import { useEffect, useRef, useState } from 'react';
const mapRef = useRef();
  useEffect(() => {
    if (!mapRef.current) return;
    const osmb = new OSMBuildings({
      container: mapRef.current.id,
      position: { latitude: 35.6620, longitude: 139.7038 },
      zoom: 15,
      tilt: 45,
      minZoom: 15,
      maxZoom: 17
    });
    // こちらも下にOSMを描画しておく
    osmb.addMapTiles('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
      { attribution: '© Data <a href="http://osm.org/copyright">OpenStreetMap</a>' });
    // さっき作ったGeoJSONタイル!
    osmb.addGeoJSONTiles(`https://storage.googleapis.com/[your bucket]/tiles3D/fuji/{z}_{x}_{y}.json`);
  }, [mapRef.current]);
return <div id="map3D" ref={mapRef} />

これがうまくいくと、めでたく3D地図が描画されます: f:id:kenkawakenkenke:20210331223034p:plain

データを見てみよう

最後に、可視化したデータから何か面白いことが見えるか探索してみましょう。 まずは標高データ。こうすると東京の0m地帯がはっきりよく見えますね: f:id:kenkawakenkenke:20210331224009p:plain 渋谷(画面中央)が谷なのがよくわかります: f:id:kenkawakenkenke:20210331224059p:plain

建物の高さを見ると、新宿の西と東が全然様相が違うことがわかりますね: f:id:kenkawakenkenke:20210331224315p:plain そしてこの高い建物群のせいで、東側広範囲にわたって富士山が見えない領域が広がっています。建築基準として日照権などは定義されていますが、「富士山見える権」みたいなのが定義されたら面白いですね。僕は欲しい。 f:id:kenkawakenkenke:20210331224414p:plain

ついでなので、「富士山🗻と東京タワー🗼が両方見える建物」も探索してみました。多分物件情報とかと組み合わせたら面白いんじゃないかと思います。「富士山・東京タワー・スカイツリーが見えるマンション検索」とか作ったり、「景勝地が複数一枚の写真に収められる写真スポット探索サイト」とか作ったり。 f:id:kenkawakenkenke:20210331230358p:plain

他にもなにか富士山の見える建物の分布で発見がないか、是非さがしてみてください:

wherecaniseefuji.web.app

まとめ

本稿ではPlateauデータの取得、分析、そして人にブラウザで遊んでもらえる可視化ページの作成までを駆け足で解説しました。 Plateauのように使いやすいデータの登場により、データや可視化好きの人はお祭り状態のように喜んで色々作っています。

個人的な思いとして、遊んだ成果物を画像や動画としてアップするだけではなく、「みんながグリグリ動かして遊べる」環境を整えることがコミュニティから次のアイディアが生まれることに繋がると考えています。そのため、是非こういったデータを使った面白いアイディアを思いついたら、本稿を参考に(してもしなくても)、ぜひ「遊べる」プレゼンテーションをしてみて頂けると書いた甲斐があります。

ではまた!