Wikidata に SPARQL を投げて多言語対応の地図注記としてつかう

[GLAMデータを使い尽くそうハッカソン](https://lab.ndl.go.jp/cms/hack2019) に参加してきました。チームとしての活動は [「GLAMデータを使い尽くそうハッカソン」に行ってきた](https://qiita.com/higa4/items/33213a50f359dd13c532) をご参照ください。このエントリは需要の多そうな要素技術を解説します。

# シナリオ

地図上で見えている緯度経度範囲内について、 Wikidata に対して検索をかけて注記として表示したい。ついでに Wikidata は多言語ラベルに対応しているはずだからそれもやってみたい。

# できあがり

最小構成にしたものを <https://gist.github.com/frogcat/9f4df3e23054ed5ff9a13cb6ffbb0a23> においてあります。実際の動作は以下のようなかんじです。

[日本語版](http://bl.ocks.org/frogcat/raw/9f4df3e23054ed5ff9a13cb6ffbb0a23/#17/35.67841/139.76427)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/82494/8ecbf497-aa56-8db0-0564-db183b306bd1.png)

[英語版](http://bl.ocks.org/frogcat/raw/9f4df3e23054ed5ff9a13cb6ffbb0a23/?en#17/35.67841/139.76427)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/82494/4741d0d2-f992-552c-0283-ef3dcc7a8bc0.png)

[中国語]
(http://bl.ocks.org/frogcat/raw/9f4df3e23054ed5ff9a13cb6ffbb0a23/?zh#17/35.67841/139.76427)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/82494/64231d9e-0039-7e95-d2fe-e8ed8293fe3d.png)


URL パラメータに `?en` とか `?zh` など、言語コードを指定するとその言語で地図注記が表示される仕組みです。対応する言語ラベルがない場合には `Q11357099` のような文字列が黒背景橙文字で表示されます。

# 解説

## SPARQL

以下の SPARQL で主語URI、主語URIに対する指定言語のラベル、緯度経度(WellKnownTextリテラル) が得られます。

```wikidata.rq
SELECT ?place ?placeLabel ?location WHERE {
SERVICE wikibase:box {
 ?place wdt:P625 ?location.
 bd:serviceParam wikibase:cornerWest "Point(${west} ${north})"^^geo:wktLiteral.
 bd:serviceParam wikibase:cornerEast "Point(${east} ${south})"^^geo:wktLiteral.
}
SERVICE wikibase:label { bd:serviceParam wikibase:language "${lang}". }
}
```

以下がパラメータになっているので利用時に値を設定する必要があります。

- ${west} : 西端の経度
- ${east} : 東端の経度
- ${south} : 南端の緯度
- ${north} : 北端の緯度
- ${lang} : 言語コード

参考にしたのはこちらのページです。
正直 `wikibase:box` とか `wikibase:label` あたりはよくわかってません。先人に感謝。

- <https://en.wikibooks.org/wiki/SPARQL/SERVICE_-_around_and_box>


## Leaflet

特別なことはしていません。[moveend Event](https://leafletjs.com/reference-1.5.0.html#map-moveend) が発生したら SPARQL をなげて結果の緯度経度・ラベルを地図上に配置するだけです。

全体でも 100行みたないコードなので以下貼っておきます。

```html

<!DOCTYPE html>
<html>

<head>
  <meta charset="UTF-8">
  <title>wikidata on leaflet</title>
  <meta name="viewport" content="initial-scale=1.0, maximum-scale=1.0" />
  <link rel="stylesheet" href="https://unpkg.com/leaflet@1.5.1/dist/leaflet.css" />
  <script src="https://unpkg.com/leaflet@1.5.1/dist/leaflet.js"></script>
  <script src="https://unpkg.com/leaflet-hash@0.2.1/leaflet-hash.js"></script>
  <style>
    .wikidata {
      white-space: nowrap;
      width: auto;
      height: auto;
      background: orange;
      border: 1px solid black;
    }

    .wikidata.q {
      background: black;
      color: orange;
    }
  </style>
</head>

<body>
  <div id="map" style="position:absolute;top:0;left:0;bottom:0;right:0;"></div>
  <script>
    var map = L.map("map", L.extend({
      zoom: 17,
      center: [35.67811, 139.7664815]
    }, L.Hash.parseHash(location.hash)));

    L.control.layers({
      "淡色地図": L.tileLayer("https://cyberjapandata.gsi.go.jp/xyz/pale/{z}/{x}/{y}.png", {
        attribution: "<a href='http://maps.gsi.go.jp/development/ichiran.html'>地理院タイル</a>"
      }),
      "標準地図": L.tileLayer("https://cyberjapandata.gsi.go.jp/xyz/std/{z}/{x}/{y}.png", {
        attribution: "<a href='http://maps.gsi.go.jp/development/ichiran.html'>地理院タイル</a>"
      }),
      "色別標高図": L.tileLayer("https://cyberjapandata.gsi.go.jp/xyz/relief/{z}/{x}/{y}.png", {
        attribution: "<a href='http://maps.gsi.go.jp/development/ichiran.html'>地理院タイル</a>"
      }),
      "写真": L.tileLayer("https://cyberjapandata.gsi.go.jp/xyz/seamlessphoto/{z}/{x}/{y}.jpg", {
        attribution: "<a href='http://maps.gsi.go.jp/development/ichiran.html'>地理院タイル</a>"
      }).addTo(map),
      "OpenStreetMap": L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
        attribution: "&copy; <a href='http://osm.org/copyright'>OpenStreetMap</a> contributors"
      })
    }).addTo(map);

    L.hash(map);

    var group = L.layerGroup().addTo(map);
    var lang = "ja";
    if (location.search.match(/^\?([a-zA-Z_]+)$/)) lang = RegExp.$1;

    map.on("moveend", function() {
      var bounds = map.getBounds();
      var sparql =
        `SELECT ?place ?placeLabel ?location WHERE {
SERVICE wikibase:box {
 ?place wdt:P625 ?location.
 bd:serviceParam wikibase:cornerWest "Point(${bounds.getWest()} ${bounds.getNorth()})"^^geo:wktLiteral.
 bd:serviceParam wikibase:cornerEast "Point(${bounds.getEast()} ${bounds.getSouth()})"^^geo:wktLiteral.
}
SERVICE wikibase:label { bd:serviceParam wikibase:language "${lang}". }
} limit 2000`;
      group.clearLayers();
      fetch("https://query.wikidata.org/sparql?query=" + encodeURIComponent(sparql), {
        "headers": {
          "accept": "application/sparql-results+json"
        },
        "method": "GET",
        "mode": "cors"
      }).then(a => a.json()).then(a => {
        a.results.bindings.forEach(x => {
          if (x.location.value.match(/^Point\((.+) (.+)\)$/)) {
            var lon = parseFloat(RegExp.$1);
            var lat = parseFloat(RegExp.$2);
            var html = "<span class='wikidata'>" + x.placeLabel.value + "</span>";
            if (x.placeLabel.value.match(/^Q[0-9]+$/)) html = html.replace("wikidata", "wikidata q");
            L.marker([lat, lon], {
              icon: L.divIcon({
                html: html
              })
            }).addTo(group);
          }
        });
      });
    }).fire("moveend");
  </script>
</body>

</html>

```

- SPARQL に Limit 句を設定して大量の結果が返ってくることを予防しています
- 現実的にはズームレベルが小さいときは注記を出さない、とか、注記の種別を自治体名に限定するなどの対応も必要そうです
- 緯度経度は Wellknown Text の Point 形式で返ってきます。まじめにやるなら [wellknown](https://www.npmjs.com/package/wellknown) などでパースするとよいですが、ここでは Point が返ってくることが明らかなので正規表現で簡単に処理しています
- 注記は L.marker に L.divIcon を設定することで表現しています。L.divIcon は何も設定しないと白背景黒ボーダーで幅高さ1em のボックス、のスタイルが設定されてしまうので、 class+CSS でちゃんとスタイルを書く必要があります
- sparql を fetch で取得する方法については [SPARQL のお供に Chrome DevTools の Copy as fetch が便利](https://qiita.com/frogcat/items/9fabda1ccc099fbf8b9a) をどうぞ