D3.js + GeoJSONで世界地図クリック式の国名暗記アプリを作った話
はじめに
白地図をクリックして国名を覚えられるブラウザツール「世界地図 — 国名暗記ツール」を個人開発で公開しました。
暗記モード(クリックした国の名前が表示される)とクイズモード(出題された国名を地図上でクリックして答える)の2つのモードがあります。フレームワークもビルドツールも使わず、素のHTML/CSS/JavaScriptとD3.js v7だけで作っています。
この記事では、地図の描画とクリック判定まわりで工夫した点を中心にまとめます。
技術構成
- HTML/CSS/JavaScript(フレームワーク不使用)
- D3.js v7(CDN経由で読み込み)
- 地図データ: Natural EarthのGeoJSON
- ビルドツールなし。ファイルをそのまま置けば動く構成
- ホスティング: GitHub Pages
個人開発でとにかく早く形にしたかったので、npmもバンドラーも入れていません。GeoJSONファイルとJSファイルを並べて<script>タグで読み込むだけの構成です。
地図を描画する
まずGeoJSONを読み込んで、d3.geoPathでSVGの<path>に変換します。
const svg = d3.select("#map");
const projection = d3.geoNaturalEarth1()
.fitSize([width, height], geojsonData);
const pathGenerator = d3.geoPath().projection(projection);
svg.selectAll("path.country")
.data(geojsonData.features)
.enter()
.append("path")
.attr("class", "country")
.attr("d", pathGenerator)
.attr("data-code", d => d.properties.ISO_A2);
d.propertiesにはNatural Earthのデータセットが持っている国名・ISOコードなどが入っているので、これをそのままクリック判定や国名表示に使えます。
クリックした国を特定する
D3 v6以降はイベントハンドラの引数が(event, d)の順になっているので注意が必要です(v5以前のd3.eventはもう使えません)。
svg.selectAll("path.country")
.on("click", (event, d) => {
const code = d.properties.ISO_A2;
const nameEn = d.properties.NAME;
const nameJa = translations[code] ?? nameEn;
showCountryName(nameJa, nameEn);
markAsLearned(code);
});
translations.jsには英語国名→日本語のマッピングを持たせていて、GeoJSON側のプロパティをキーにして日本語名を引いています。
小さい国のクリック判定という壁
これが一番苦労したポイントです。バチカン市国やシンガポール、カリブ海の島国などは、世界地図くらいの縮尺で表示すると1〜2pxしかない国もあり、狙ってクリックするのがほぼ不可能でした。
対策として有効だったのが以下の2つです。
① クリック判定用に見えないstroke(線の太さ)を足す
.country {
stroke-width: 0.5px;
stroke: transparent;
}
.country {
/* 見た目の線は細くても、クリック判定は太めにする */
paint-order: stroke;
}
.country:hover {
stroke-width: 3px; /* ヒット領域を広げる */
}
塗り(fill)だけでなくstrokeにも太めの透明な線を乗せておくと、境界線ギリギリや細長い国でもクリックを拾いやすくなります。
② 極端に小さい国は別レイヤーで丸印(ピン)を追加する
面積が閾値以下の国は、中心座標(pathGenerator.centroid(d)で取得できます)に小さい円を重ねて表示し、そちらもクリック対象にしました。地図上の見た目はそのままに、クリックできる範囲だけ広げるイメージです。
const smallCountries = geojsonData.features.filter(d => d3.geoArea(d) < THRESHOLD);
svg.selectAll("circle.click-target")
.data(smallCountries)
.enter()
.append("circle")
.attr("cx", d => pathGenerator.centroid(d)[0])
.attr("cy", d => pathGenerator.centroid(d)[1])
.attr("r", 6)
.attr("fill", "transparent")
.on("click", (event, d) => handleCountryClick(d));
学習進捗をlocalStorageに保存する
暗記モードでクリックした国はlocalStorageに貯めていって、次回訪問時も進捗が引き継がれるようにしています。
function markAsLearned(code) {
const learned = new Set(JSON.parse(localStorage.getItem("learnedCountries") ?? "[]"));
learned.add(code);
localStorage.setItem("learnedCountries", JSON.stringify([...learned]));
updateProgressBar(learned.size, TOTAL_COUNTRIES);
}
Setにしているのは重複チェックを楽にするためで、保存するときだけ配列に戻しています。全カ国クリックし終えると祝福メッセージを出す、というだけの単純な仕組みですが、進捗が可視化されると地味にモチベーションが続きます。
クイズモードのロジック
クイズモードは「国名を出題 → 地図上でその国をクリック → 正誤判定」という流れです。
function nextQuestion(pool) {
const target = pool[Math.floor(Math.random() * pool.length)];
showQuestion(translations[target.properties.ISO_A2]);
svg.selectAll("path.country").on("click", (event, d) => {
const isCorrect = d.properties.ISO_A2 === target.properties.ISO_A2;
recordAnswer(isCorrect);
isCorrect ? showCorrectFeedback() : showIncorrectFeedback(target);
nextQuestion(pool.filter(c => c !== target));
});
}
出題数(10問/30問/全カ国)に応じてプールを絞り込み、スキップ機能は「不正解として記録して次の問題に進む」形で実装しています。結果画面では正答数と出題数から正答率を出しているだけのシンプルな作りです。
今後やりたいこと
- 大陸別のページを増やして、「アジアの国名クイズ」のような検索意図をSEOで拾えるようにする
- クイズ結果のシェアボタン(OGP画像つき)でSNS経由の流入を増やす
- ある程度アクセスが集まったらGoogle AdSenseへの申請を検討
まとめ
フレームワークもビルドツールもなしでも、D3.jsとGeoJSONだけでインタラクティブな地図アプリは十分作れます。特に「小さい国をどうクリックさせるか」は地図系のアプリを作る人には共通の悩みだと思うので、同じところで詰まっている人の参考になれば嬉しいです。
サイトはこちらです。よければ触ってみてください。