Digital 法寶義林 (Hôbôgirin) の作り方/TEIファイルから地図年表マッピング-その1

先週の土曜日、人文情報学による仏教知識構造化の新潮流 ​というシンポジウムがあり、 そこでDigital 法寶義林 (Hôbôgirin) というサイトが公開されました。このサイトは インターフェイスをJavascriptで作り込んでおり、インターフェイスからサーバ側にリクエストがあると、 サーバ側ではTEI/XMLで書かれたデータ(ファイル)をJSONに変換して返戻し、インターフェイス(Javascript)側では それを地図や年表などにプロットする、という風になっています。全体的にはよくある構成ですが、 サーバ側に置いてあるのがTEI/XMLファイルである、という点が、よくあるやり方とはちょっと違っています。

基本的に、サーバ側でデータを扱う時は、データサイズが大きくなっても大丈夫なように、検索に特化された ソフトウェアを使うのが一般的です。これも用途やデータ形式によっていくつか選択肢があり、それぞれ フリーソフトウェアで公開されていますが、たとえば、サーバ上でデータ更新まですることを 前提に表形式のデータや表を組み合わせた形式のデータを用いるのであれば、 伝統的にはリレーショナルデータベースシステム(RDBMS)であるPostgreSQLやMySQLを用いる ことが多いように思います。 検索に特化する上にデータ量が多い場合には、Apache Luceneというフリーの全文検索ライブラリ があり、これを組み込んだ全文検索ソフトウェアとしてApache SolrやElasticsearchなどがよく 用いられます。特にElasticsearchは、ジャパンサーチやWeko3などの公的なものでも利用されて おり、最近はとくに流行っているようです。数百万件のデータでも一瞬で検索できます。

しかしながら一方で、最近はコンピュータもかなり速くなってしまい、テキストデータでも 10MBくらいのデータなら一瞬で処理できてしまいます。そこで、サーバ側でデータ更新をすることが なく、それほど大きくない上に急激にデータ量が増える見通しもない場合には、CSVやTSV、あるいはJSONやXMLのファイルを そのままプログラムで開いて処理してしまうことも十分に選択肢に入ってきます。 今回のDigital 法寶義林では、そのような構成(サーバ側ではPHPでTEI/XMLファイルを開いて処理)に なっています。PHPにはSimpleXMLというモジュールが用意されており、これを用いると 簡単にXMLをパース(XMLのルールに沿って読み込んで処理できるようにする)できます。 ただ、サーバ側のプログラムは別にPHPである必要はなく、むしろ、Python3のBeautifulsoupという モジュールの方がPHPのSimpleXMLよりもずっと使いやすいです。

というわけで、データの取り出し方ですが、今回のDigital 法寶義林のデータ(注:4MBあります)をみてみると、 普通のTEI/XMLですが、全体として、本文部分(<body>)と後付(<back>)に分かれていることがまずはみてとれます。

Oxygen XML Editorで<body>タグと<back>タグ等を畳んでみたところ

それぞれのタグには3万行以上が含まれているようで、もう眼で確認するには難しい分量ですが、とりあえず本文部分をもう少しみて みますと、<listPerson>というタグが入っています。

<body>の中に<listPerson>がある

一方、後付の中には、以下のように、いくつかの <list...>タグが含まれています。

後付に含まれるリスト系タグ群

つまり、このデータでは、参照用に用いられるデータを<back>にリストして、そこにリストされたデータを本文の人名情報から参照する、という形になっています。 このうちの<listPlace>には、type="place"とついているものとtype="vih"とついているものがあります。これは、type属性を用いて、地名情報を、一般的な地名と寺院名に分けています。そこで、寺院の地名情報を開いてみていると以下のようになっています。

寺院の地名情報

特に <place xml:id="vh0001"> 以下に注目してみましょう。ここではまず、地名の表記の仕方を<placeName>で列挙しつつxml:lang属性で区別しています。 次に実際の場所を<locaton>の中の<geo>タグの中に座標データとして記述しています。座標情報は、人によって判断が異なることもあるため、誰に責任があるかということをresp属性で示し、さらに、情報源がある場合はsource属性で示しています。この、respやsourceといった属性の中身は、地図上にこのお寺をマッピングする際にはまったく不要な情報ですが、しかしこの情報の根拠を確認したくなったときには重要です。TEI/XMLのファイルでは、このようにして、すぐには使用しなくても書いておきたい情報をとりあえず記述して流通させることができます。

さて、このような感じのデータですので、とりあえず寺院の情報を地図上にマッピングしてみることを考えてみましょう。

地図上にマッピングする方法概観

まず、地図上にマッピングするデータを作成します。これは、上記のように、TEI/XMLファイルからマッピング用のデータを抽出するプログラム等を作成すれば大丈夫です。このプログラムは比較的簡単なものです。検索してデータを絞り込む等の機能をプログラムに追加しておくと、より便利になるでしょう。

次に、地図上にマッピングするのですから、地図を使えるようにする必要があります。地図上にマッピングする際に簡便に使えるのは、Leaflet という Javascriptのライブラリです。お金をかけずに済ませようとするなら、これに OpenStreetMapを組み合わせるのがおすすめです。これを用いて、Webブラウザ上で地図を表示してぐりぐり動かせるようにします。

その上で、この地図に、TEI/XMLファイルから取り出したデータをマッピングすることになります。

地図上にマッピングしてみる:データ抽出編

では、まずはデータの作成です。TEI/XMLファイルから抽出する、と言われても何をどうしたらいいのか…という人もいれば、すぐにできる、という人もいるでしょう。 とりあえず、Python3がある、という前提でやってみましょうか。

Python3では、XMLファイルを簡単に処理するための便利なモジュールとして Beautifulsoupがあります。とりあえずこれをインストールすると、目当てのタグ/属性でデータを抽出できるようになります。Python3(dictの順序が一定になるバージョン3.6以降をおすすめします)をインストールした環境で

$ sudo pip3 install bs4

などとするとBeautifulsoupが使えるようになります。

ここで、たとえば、以下のように書くと、登録されている寺院の名前だけを取り出すことができます。

#!/usr/bin/env python3
from bs4 import BeautifulSoup
import sys
fname = sys.argv[1]
with open(fname , encoding='utf-8') as f:
    all_data = BeautifulSoup(f, 'xml')
    listPlaces = all_data.select('listPlace[type="vih"]')
    for places in listPlaces:
        place = places.select('place')
        for placeInfo in place:
            placeNames = placeInfo.select('placeName[xml\:lang="zh"]')
            for pl in placeNames:
                print (pl.get_text().strip())

(ここではわかりやすくするため、CSSセレクタの記法と近い .select() を使用しています) ここで何をしているのかと言えば、寺院の名前は <listPlace type="vih">の中にエレメントとしてリストされているので、 まずはそれを取り出します。(listPlacesに入れる)。それから、listPlacesの中にある<place>を取り出して、 さらにその中にあるplaceNameのうち、漢字で書かれたもの(ここではxml:lang="zh"が付与されている)を 取り出して、 .get_text() を用いて取り出して表示する、という処理を行っています。前後に空白がついている ことがあるのでそれを削除するために .strip() も用いています。

このスクリプトを extract.py としておいて、以下のようにすれば寺院名がリストされるはずです。

$ python3 extract.py hobogirin_tei.xml

このようにして地名を取り出すことができました。次に、座標情報がほしいですね。これは、以下のようにして 取り出して寺院名と交互に表示できます。

#!/usr/bin/env python3
from bs4 import BeautifulSoup
import sys
fname = sys.argv[1]
with open(fname , encoding='utf-8') as f:
    all_data = BeautifulSoup(f, 'xml')
    listPlaces = all_data.select('listPlace[type="vih"]')
    for places in listPlaces:
        place = places.select('place')
        for placeInfo in place:
            placeNames = placeInfo.select('placeName[xml\:lang="zh"]')
            for pl in placeNames:
                print (pl.get_text().strip())
            #以下、追記部分
            locations = placeInfo.select('location')
            for location in locations:
                geos = location.select('geo')
                for geo in geos:
                    print (geo.get_text())

さて、このようにして取り出せるとなると、次はこれをJavascriptで扱いやすいようにJSONに変換したくなってしまいますね。そこでjsonモジュールを導入です。 …といっても、jsonモジュールは標準で入っているようですのでわざわざインストールする必要はなさそうです。

そこで、このデータをdictとlistを使ってさくっとJSON形式にしてみましょう。

#!/usr/bin/env python3
from bs4 import BeautifulSoup
import sys
import json
# jsonモジュールを読み込み
fname = sys.argv[1]
temple_list = []
# この配列↑に、JSON形式の抽出結果に入れるべき寺院のデータを追加すべく1回だけ初期化
with open(fname , encoding='utf-8') as f:
    all_data = BeautifulSoup(f, 'xml')
    listPlaces = all_data.select('listPlace[type="vih"]')
    for places in listPlaces:
        place = places.select('place')
        for placeInfo in place:
            temple_dict = {}
            #この辞書↑に個々の寺院のデータ(名前と座標)を入れるべく毎回初期化
            placeNames = placeInfo.select('placeName[xml\:lang="zh"]')
            for pl in placeNames:
                temple_dict['temple'] = pl.get_text().strip()
                #先ほどはただ出力していた寺院名をtemp_dict辞書に"temple"をキーとして格納
            locations = placeInfo.select('location')
            for location in locations:
                geos = location.select('geo')
                for geo in geos:
                    geolist = geo.get_text().strip().split(',')
                    #座標情報のカンマで区切られた緯度経度データをカンマで区切ってそれぞれ配列の要素としてgeolist配列に格納
                    temple_dict["lat"] = geolist[0].strip()
                    temple_dict["lon"] = geolist[1].strip()
                    # temp_dict辞書に緯度経度をそれぞれ格納。
            temple_list.append(temple_dict)
            # temp_list配列にtemp_dict辞書=寺院の情報を追加

print ('var temples = '+json.dumps(temple_list, indent=2, ensure_ascii=False))
# 最後に、すべてのデータをtemp_listに入れ終わったら json_dumpsでJSON形式にして出力

というような感じで、とりあえず漢字の寺院名と座標情報をJSON形式で出力できるようになります。これをリダイレクトで 出力して、静的なJSONファイルを作っておきましょう。たとえば以下のような感じです。

$ python3  extract.py hobogirin_tei.xml > all_temples.js

このall_temples.jsというファイルを、Javascriptの地図に読み込めるようにするのが次のステップです。

なお、同様にして、<listPlace type="place">をターゲットにすると、寺院名ではなく地名の情報がとれます。 また、placeInfo.select('placeName[xml\:lang="zh"]')zhを他の言語タグに切り替えればその言語タグの寺院名を取得できます。 データを見ながら色々試してみると面白いかもしれません。

地図上にマッピングしてみる:地図表示編

さて、次に、地図をWebブラウザ上に表示してみます。これは、上述のようにLeafletを使うのですが、Web上で参照可能なライブラリ として公開されていますので、それを使ってさくっと作ってしまいます。

<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="format-detection" content="telephone=no">
<title>タイトル</title>
<meta name="description" content="test for mapping">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.7.1/dist/leaflet.css"
integrity="sha512-xodZBNTC5n17Xt2atTPuE1HxjVMSvLVW9ocqUKLsCC5CXdbqCmblAshOMAS6/keqq/sMZMZ19scR4PsZChSR7A=="
crossorigin=""/>
<style type="text/css">
    <!--
     #leafletmapid { height: 400px; width: 600px}
   -->
   </style>
</head>
<body>
<h1>test for mapping</h1>
    
<div id="leafletmapid"></div>
<script src="https://unpkg.com/leaflet@1.7.1/dist/leaflet.js"
   integrity="sha512-XQoYMqMTK8LvdxXYG3nZ448hOEQiglfqkJs1NOQV44cWnUrBc8PkAOcXy20w0vlaXaVUearIOBhiXZ5V3ynxwA=="
   crossorigin=""></script>
<script>
   var mymapt = L.map('leafletmapid').setView([34.058, 115.548], 4);
   var tileLayer = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',{attribution: '© <a href="http://osm.org/copyright">OpenStreetMap</a> contributors, <a href="http://creativecommons.org/licenses/by-sa/2.0/">CC-BY-SA</a>', maxZoom: 19});
     tileLayer.addTo(mymapt);
    var marker = L.marker([35.7101, 139.8107]).addTo(mymapt);
</script>
</body>
</html>

これを test.html 等のファイル名で保存して、ネットにつながったパソコン上でGoogle Chrome等で開くと、Leafletで表示したOpenStreetMap上にマーカーが一つプロットされていると思います。以下のような感じですね。

ここに、先ほどのJSONデータを読み込ませたいのですが…とりあえずちょっとやってみましょう。

<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="format-detection" content="telephone=no">
<title>タイトル</title>
<meta name="description" content="test for mapping">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.7.1/dist/leaflet.css"
integrity="sha512-xodZBNTC5n17Xt2atTPuE1HxjVMSvLVW9ocqUKLsCC5CXdbqCmblAshOMAS6/keqq/sMZMZ19scR4PsZChSR7A=="
crossorigin=""/>
<style type="text/css">
    <!--
     #leafletmapid { height: 400px; width: 600px}
   -->
   </style>
</head>
<body>
<h1>test for mapping</h1>
    
<div id="leafletmapid"></div>
<script src="https://unpkg.com/leaflet@1.7.1/dist/leaflet.js"
   integrity="sha512-XQoYMqMTK8LvdxXYG3nZ448hOEQiglfqkJs1NOQV44cWnUrBc8PkAOcXy20w0vlaXaVUearIOBhiXZ5V3ynxwA=="
   crossorigin=""></script>
<script src="all_temples.js"></script>
<!-- ↑このタグで、先ほど作成したJSONデータを読み込み-->
<script>
    var mymapt = L.map('leafletmapid').setView([34.058, 115.548], 4);
    var tileLayer = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',{attribution: '© <a href="http://osm.org/copyright">OpenStreetMap</a> contributors, <a href="http://creativecommons.org/licenses/by-sa/2.0/">CC-BY-SA</a>', maxZoom: 19});
     tileLayer.addTo(mymapt);
    temples.forEach(function(e){
         // ↑ forEachで JSONデータがリストで入っている変数temples内のリスト要素(=寺院情報)を一つずつ変数eに入れて処理。
        if(e.lat != undefined){
            var marker = L.marker([e.lat, e.lon]).addTo(mymapt);
                                          //↑ここの e.lat とe.longでJSONデータ中の緯度経度情報を取り出して読み込ませる 
        }
    });
    // ↑ここのforEach()の繰り返し処理で、寺院のデータを繰り返し処理。
</script>
</body>
</html>

これでうまくいくと以下のようになるはずです。地図を拡大してみると、少し見やすくなると思います。

さて、寺院名が出ないと何が何だかわかりませんね。寺院名はJSONデータに temple というキーで格納してありますから、これをマーカーにポップアップで表示されるようにすればOKです。ちょっと端折りますが、上のHTMLの繰り返し処理のところに以下のように追記します。

    temples.forEach(function(e){
        if(e.lat != undefined){
            var marker = L.marker([e.lat, e.lon]).addTo(mymapt);
            marker.bindPopup(e.temple);
            // ↑ この行を追記。e.templeで変数eからキーtempleの値を取り出して読み込ませる
        }
    });

そうすると、以下のように、マーカーをクリックするとポップアップで表示されます。

さて、これだとやはりちょっと見づらいですね。何が見づらいかって、マーカーが重なりすぎて何がなんだかわかりません。 こういうときは、マーカーをまとめてくれるJavascriptライブラリがありますのでそれを組み込んでみます。以下のような感じにします。

<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="format-detection" content="telephone=no">
<title>タイトル</title>
<meta name="description" content="test for mapping">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.7.1/dist/leaflet.css"
integrity="sha512-xodZBNTC5n17Xt2atTPuE1HxjVMSvLVW9ocqUKLsCC5CXdbqCmblAshOMAS6/keqq/sMZMZ19scR4PsZChSR7A=="
crossorigin=""/>
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.3.0/dist/MarkerCluster.css" />
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.3.0/dist/MarkerCluster.Default.css" />
<!-- ↑この二つのCSSファイルを追加します。-->
<style type="text/css">
    <!--
     #leafletmapid { height: 400px; width: 600px}
   -->
   </style>
</head>
<body>
<h1>test for mapping</h1>
    
<div id="leafletmapid"></div>
<script src="https://unpkg.com/leaflet@1.7.1/dist/leaflet.js"
   integrity="sha512-XQoYMqMTK8LvdxXYG3nZ448hOEQiglfqkJs1NOQV44cWnUrBc8PkAOcXy20w0vlaXaVUearIOBhiXZ5V3ynxwA=="
   crossorigin=""></script>
<script src="https://unpkg.com/leaflet.markercluster@1.3.0/dist/leaflet.markercluster.js"></script>
<!-- ↑このスクリプトを追加します。-->
<script src="all_temples.js"></script>
<script>
    var mymapt = L.map('leafletmapid').setView([34.058, 115.548], 4);
    var tileLayer = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',{attribution: '© <a href="http://osm.org/copyright">OpenStreetMap</a> contributors, <a href="http://creativecommons.org/licenses/by-sa/2.0/">CC-BY-SA</a>', maxZoom: 19});
     tileLayer.addTo(mymapt);
    var markers = L.markerClusterGroup();
    // markersという変数でマーカーをクラスタできるように初期化しておきます
    temples.forEach(function(e){
        if(e.lat != undefined){
            var marker = L.marker([e.lat, e.lon]);
            marker.bindPopup(e.temple);
            markers.addLayer(marker);
            //今回は、markersにmarkerをどんどん追加していきます。
        }
    });
    mymapt.addLayer(markers);
    //すべての寺院情報がmarkersに追加されたら、それを地図に追加します。
</script>
</body>
</html>

うまくいけば、以下のようにしてマーカーがクラスタとして表示されて、地図を拡大すると適宜マーカーが分散表示されるようになります。

というわけで、TEI/XMLのデータを地図上にマッピングして簡単に閲覧できるところまできました。とりあえず今日はこの辺にしておきたいと思います。