前置き
geoguessrとは?
Googleのストリートビューを利用した場所あてクイズで、看板や車両から国・地域を推定していく地理好きにはたまらないゲーム。以前このサイトを初めて目にした際その面白さにドハマリしました。
地理ネタと鉄道ネタと最近APIが改定されたGoogleMapに代わるものとして知った leaflet の勉強の合わせ技として、「この鉄道の駅は何駅でしょうゲーム」を作成しました。
leafletとは?
GoogleMapと同じように画面上にマップを生成するJavascriptのオープンソースライブラリーです。つかいかたもGoogleMapAPIとあまり変わらない感じで使いやすいと思います。また、このleaflet、地図情報のタイルを変えることで地図以外でも、地質図やドラクエなどのオリジナルマップの表示、画像表示の用途にも使えるみたいです。
#実際にできたもの
GitHubPagesで簡単に公開できるらしいのでせっかくなので利用してみました。
どう作るか
必要なデータ
- マップ・空中写真
- 駅の座標データと駅名データ
1番目のデータにははじめは国土地理院の地図を利用するつもりでしたが、よく考えてみれば地図には駅名が書かれているわけでクイズになりません。ということで空中写真を利用することになりました。
(ちなみにGoogleMapを利用すれば、駅名などのラベルを消した、道路や路線しか書かれていない地図を利用することができるのですが、手続きが面倒なのであきらめました。)
2番目のデータは駅データ.jpという、求めていたものが全て入った完璧なサービスがあったのでそれを利用させていただくことにしました。ありがとうございます。
また、APIで呼び出す事も考えましたが、駅名当てクイズとあまり相性の良くないデータ型だったため、断念してデータをダウンロードで利用する形になりました。
流れ
- 駅データからランダムで一つ出題する駅を抽出する
- 正解となるその駅を地図に表示する。
- 他のダミー選択肢を選んでくる。
- 回答者によって選択された駅の正誤の判断をする。
必要な技術
- 根幹となるページの動きにはVueを利用(使いやすいですね。)
- デザインは面倒なのでbootstrap4を利用
- マップは先述の通りleaflet.js
#ソースコード
自分の勉強も兼ねて解説していきます。
以下はメインページの抜粋です。
...
<div id="app">
<!-- この要素内がvueで操作するものとなります。-->
<div class="modal" id="answer_area" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">正解は...</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">×</span>
</button>
</div>
<div class="modal-body">
<p>{{ train_line_name }}</p>
<h2>{{ train_station_name }}</h2>
<h3> {{ result }}</h3>
<!-- vue では{{}}の中に変数名を書くとそこを置き換えて表示してくれます。
jQuerryとはまた違ったやり方ですが、こちらの方が直接的で理解しやすいと思います。-->
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary " v-on:click="restart">次へ進む</button>
<!-- このボタンをクリックするとvue側で定義したrestart関数が発動する。-->
<button type="button" class="btn btn-secondary" v-if="!IsCorrect" data-dismiss="modal">戻って見てみる</button>
<!-- 不正解の場合はこのボタンが表示され、見直すことができる。-->
</div>
</div>
</div>
</div>
<div id="quiz_area">
<ul class="text-center">
<li class="btn btn-outline-info btn-lg" v-for="option in options" v-on:click="answer">{{ option.split(",")[2] + "駅"}}</li>
</ul>
<!-- JS側で作成した選択肢が入ったoptionsという配列内の要素をlist表示します。
このようにちょっとした操作をわざわざJSで行わなくても表示できるのがvueのいい所です。-->
</div>
</div>
<div id="map">
<!-- ここにマップが入ってきます。CSSでサイズをうまく設定しないと
高さが0などになって表示されないことがあるので注意が必要 -->
</div>
...
##要点
- 地図を表示する際、他の要素内に組み込むとサイズが0になることがあるので、CSSで指定してあげなければならないです。詳しくは検索してみるといろいろ出てきます。
<!-- body下部に書くvueのメインとなる部分です。-->
<script>
//以下のURLで国土地理院から持ってくる地図の種類を変更できます。
//通常地図:https://cyberjapandata.gsi.go.jp/xyz/std/{z}/{x}/{y}.png
//空中写真:https://cyberjapandata.gsi.go.jp/xyz/ort/{z}/{x}/{y}.jpg
//シームレス画像:https://cyberjapandata.gsi.go.jp/xyz/seamlessphoto/{z}/{x}/{y}.jpg
var quiz_data;
var map = L.map('map');
//mapというidが付与された要素にmapを描画します。
L.tileLayer('https://cyberjapandata.gsi.go.jp/xyz/seamlessphoto/{z}/{x}/{y}.jpg', {
attribution: "<a href='https://maps.gsi.go.jp/development/ichiran.html' target='_blank'>地理院タイル</a>|駅データ:<a href='http://www.ekidata.jp' target='_blank'>駅データ.jp</a>"
}).addTo(map);
//タイルレイヤーに国土地理院の地図を入れます。
//また、地図の右下に引用元を記載できます。
var app = new Vue({
el: '#app',
data: {
train_line_name: '',//正解とする路線名
train_station_name: '',//正解とする駅名
IsCorrect: false,//正解かどうか
options: [],//選択肢
result:""//「正解」か「不正解」を表示する欄
},
methods: {
//ここで独自の関数を設定できます。
answer: function(event) {
$("#answer_area").modal('show');
//jQuerryでやる以外の簡単な方法が思いつきませんでした。
//bootstrapのJS操作ととvueの親和性はあまり良くないみたいですね。
//何か簡単にできる方法がありましたら教えていただけるとありがたいです。
if(event.target.innerText == quiz_data.answer_data.name + "駅"){
//ちょっとここは問題がありそうですが、大目にみてくださいm(_ _)m
app.result = "正解❗";
app.IsCorrect = true;
}else{
app.result = "❌不正解❌";
app.IsCorrect = false;
}
},
restart: function(event){
$("#answer_area").modal('hide');
render_station();
//station.js側で定義した関数です。
}
}
})
</script>
##要点
leafletはタイルを変えるだけで様々な地図を表示できる。
BootstrapをJSで動かす際、やはりjQuerryが必要となるので、jQuerryを使わず簡単にできる方法はまだ模索中。何かあったら教えていただけると幸いです。
以下はJSのファイルです。
//</body>タグの直前で読み込みます。
//定義する関数たち
//render_station() : 地図の情報・選択肢の情報を統合して画面に表示します。
//get_station() : 駅データが格納されたcsvファイルからランダムに4つ駅データを取得してきます。
//map_render(map,駅のデータ) : 地図に駅の座標データを取り込んで表示します。
//get_line_name(駅データ) : 駅データ内に路線情報がないのでもう一つのデータから路線名を検索して引っ張ってくる。
//random_int_array(最大,個数) : ランダムな整数の配列を返す。
//基本的に大きなデータを扱うのでpromiseを使っています。
//まだpromiseの扱いには慣れていないので不適切なやり方などあると思いますので、何かありましたら教えていただけると幸いです。
//駅データの型:
// lat: "42.626353"
// line_id: "11102"
// line_name: Array(2)
// 0: "JR函館本線(長万部~小樽)"
// 1: "ハコダテホンセン"
// line_pref: "1"
// lon: "140.313353"
// name: "蕨岱"
function render_station() {
get_station().then((back) => {
//backに選択肢・正解・正解となる駅のデータが格納されて返ってくる。
quiz_data = back;
return map_render(map, back);
}).then((back)=> {
app.options = back.options;
//駅データ選択肢をリスト表示します。
}).catch(e => {
console.log("ERROR",e);
});
}
const get_station = function() {
return new Promise(function(resolve, reject) {
var link = "station_data/stations_kanto.csv";
var options = 4;
var req = new XMLHttpRequest(); // HTTPでファイルを読み込むためのXMLHttpRrequestオブジェクトを生成
req.open("get", link, true); // アクセスするファイルを指定
req.send(null); // HTTPリクエストの発行
req.onload = function() {
//読み込まれ次第、ランダムに一要素をとってくる。
var str = req.responseText;
var list = str.split("\n");
//list:全駅データの配列
var station_options = [];//正解・ダミーを含む選択肢
var answer_station_data = {};//正解データだけ
var random_numbers = random_int_array(list.length, options);//[4つのランダムな路線番号]
var answer_num = Math.floor(Math.random() * (options));//0~3のうち一つのランダムな数字(正解となる選択肢が4つのうち何番目かを指定。)
for(var i=0; i<random_numbers.length; i++){
station_options.push(list[random_numbers[i]]);//選択肢配列に格納
}
var station_chosen = list[random_numbers[answer_num]].split(",");
//正解となる選択肢を全駅データの配列から選択してくる。
get_line_name(station_chosen[5]).then(line_name => {
answer_station_data.name = station_chosen[2];
answer_station_data.lon = station_chosen[9];
answer_station_data.lat = station_chosen[10];
answer_station_data.line_id = station_chosen[5];
answer_station_data.line_pref = station_chosen[6];
answer_station_data.line_name = line_name;
//正解となる駅データの情報を整理して格納。
resolve({
options : station_options,
answer : station_chosen,
answer_data : answer_station_data
});
});
}
});
};
const map_render = function(l_map, quiz_data) {
return new Promise(function(resolve, reject) {
let station_data = quiz_data.answer_data;
l_map.setView([station_data.lat, station_data.lon], 16);
//駅の座標を中心としてマップをセット
L.marker([station_data.lat, station_data.lon]).addTo(map);
//駅の座標にピンを立てる
app.train_station_name = station_data.name + "駅";
app.train_line_name = station_data.line_name[0];
//vueのプロパティに答えのデータをセット
resolve(quiz_data);
});
}
function random_int_array(max, how_many){
//ランダムにmax以下のhow_many個の整数を配列で返す関数です。
var result = [];
for(var i = 0;i<how_many; i++){
let tmp_int_random = Math.floor(Math.random() * (max + 1));
if (!result.includes(tmp_int_random)) {
result.push(tmp_int_random);
}
}
return result;
}
##要点
- 駅データからランダムで4つ駅データを取ってくる。
- 4つのうちからランダムで1つを正解とする。
- 正解となる駅データについて路線データを取得する。
- マップを作り、そこに正解となる駅を表示する。
- 選択肢を表示する。
- 選択肢が押されたら正誤の判定をする。
- 次へ進む(次の問題)
という流れになりました。案外単純ですね。
割と大きなデータを扱うため、promiseを使わないと順番通りに処理されず、エラーを吐く場合もあるので注意が必要です。まだpromise初心者のため不適切な書き方をしているかもしれませんが、そのようなことがありましたら、ぜひ教えていただけると幸いです。
#まとめ
駅データをダウンロードしてきた際、日本の駅の多さにびっくりしました。
あまりデータが多いと、検索などに時間がかかるなど問題点も多くなってしまうため、今回は関東地区に限定しました。データを差し替えてコードをちょっといじれば他の地区版も作れると思います。
また、データさえあれば駅だけでなく、空港・城・インターチェンジなどのクイズも作れそうです。(需要はなさそうですが)