Leafletで作るコンビニ分布マップ──MarkerClusterで16,000件を可視化する
1人アドベントカレンダー2025 9日目。
今日は技術記事です。Leaflet + MarkerClusterを使って、全国のコンビニ分布を可視化するWebアプリを作りました。
この記事のポイント:
- 16,000件のマーカーを快適に扱うコツ(
removeOutsideVisibleBoundsの恩恵) - ブランド別にMarkerClusterGroupを分けると、視認性とフィルタリングが劇的に改善する
-
disableClusteringAtZoomを動的に変更してクラスター解除を制御する方法
作ったもの
全国約16,600店舗のコンビニをマップ上に表示するWebアプリです。

leaflet-konbini-mapのWebアプリイメージ
GitHub: https://github.com/megusunu/leaflet-konbini-map
💡 応用のポイント:
stores.jsonを差し替えれば、あなたのデータも同じように可視化できます。詳しくは「発展:自分のデータで使う」セクションをご覧ください。
機能:
- MarkerClusterによるクラスタリング表示
- ブランド別フィルタリング(9ブランド対応)
- クラスター解除機能(Zoomレベル指定)
- 現在のZoomレベル表示
アーキテクチャ:
なぜコンビニ分布マップ?
Leaflet + MarkerClusterの記事を書こうと思ったとき、「実用的で、データ数が多くて、誰もが興味を持てる題材」を探していました。
コンビニは条件にぴったりでした。
- データ数が多い(16,000件超)
- 誰もが知っている
- 地域特性が見える(セイコーマートは北海道に集中、など)
- OpenStreetMapからデータ取得が可能
このWebアプリはこんな人向けに作っています。
- 地図情報に表示したり分析したい緯度経度の情報は持っているけど、やりたいことをGoogleマップで実現するとAPI使用料がかかるしなー…。
- OSM(Open Street Map)はやりたいことするのにAPIで簡単に実装は難しそうだしなー…。
なーんて思っている人向けにOSM+Leafletの機能を魅力的 かつ 簡単に(データを差し替えるだけで)応用できる環境を提供できたら面白いのでは?
と思い、無料機能でここまでできる!が伝わるマーカー量を持っているコンビニマップを作ることにしたわけです。
コード書きたくない人も、ぜひ、leaflet-konbini-mapを動かしてOSM+Leafletの魅力を味わってください!!
この構成をそのまま利用すれば、あなたが保有する緯度経度データも、ほぼ同じコードで可視化できますよ!!
技術スタック
- クライアント: JavaScript (Vanilla)
- 地図ライブラリ: Leaflet 1.9.4
- クラスタリング: Leaflet.markercluster 1.4.1
- サーバー: Flask(静的ファイル配信のみ)
- 地図タイル: OpenStreetMap
- コンテナ: Docker
なぜLeaflet + OpenStreetMapか
Google Maps APIは使用量に応じた従量課金ですが、Leaflet + OpenStreetMapは無料で利用可能です。16,000件のマーカーを表示しても、APIコストを気にせず開発・運用できます。
データ準備:Overpass APIを使ってOpenStreetMapから取得する
店舗データはOpenStreetMapからOverpass APIを使って取得しました。
例えば、日本国内のセブンイレブンを取得するクエリはこんな感じです。
[out:json][timeout:60];
area["name"="日本"]->.japan;
(
node["brand"~"セブン|7-eleven|7 eleven",i](area.japan);
way["brand"~"セブン|7-eleven|7 eleven",i](area.japan);
);
out center;
補足(node/way/out centerについて):
-
node: 単一の座標点として登録されたデータ -
way: 建物の輪郭(ポリゴン)として登録されたデータ -
out center: wayの場合は中心点の座標を取得する
OSMでは店舗がnode(点)とway(建物輪郭)の両方で登録されているため、両方を取得しています。out centerを付けることで、wayも単一座標として扱えます。
これを各ブランドで実行して、JSONに変換しました。
補足: 実務で安定運用するなら、
area["ISO3166-1"="JP"][admin_level=2]のような国コード指定の方が、地名の表記揺れに強いです。
最終的なデータ構造はシンプルです。
{
"stores": [
{
"latitude": "35.6812",
"longitude": "139.7671",
"address": "東京都千代田区...",
"name": "セブンイレブン 〇〇店",
"type": "seven-eleven"
}
],
"count": 16638
}
※ OSMの情報自体にaddressやnameの元情報が正しく入っているものが少なく、name="セブンイレブン"としか入っていない場合も多くありました。
また、そもそも登録されていない店舗は情報がなく吸い出せないですし、抽出スクリプトも全てのコンビニデータを抽出できるルールではないです(OSM特有の統一されたデータ名になっていない情報の揺れの可能性もあります)。
このため、stores.jsonデータに保存したJSONデータはそれぞれのコンビニの公式情報とは差があることをご了承ください。
データは OpenStreetMap から取得しています。
© OpenStreetMap contributors(ライセンス)
Leaflet + MarkerClusterの基本
MarkerClusterGroupの作成
16,000件のマーカーをそのまま表示すると、地図が重くなります。MarkerClusterを使うと、近くのマーカーをまとめて表示してくれます。
const markerCluster = L.markerClusterGroup({
chunkedLoading: true, // 非同期読み込み
maxClusterRadius: 60, // クラスター化する半径
spiderfyOnMaxZoom: true, // 最大ズームでスパイダー表示
showCoverageOnHover: false, // ホバー時の範囲表示を無効化
disableClusteringAtZoom: 10 // Zoom10以上でクラスター解除
});
removeOutsideVisibleBoundsオプション
MarkerClusterにはremoveOutsideVisibleBoundsというオプションがあります。デフォルトでtrueです。
これが有効だと、画面外のマーカーは描画対象から除外されるため、パフォーマンスが向上します。16,000件のデータを扱う場合、このオプションのおかげで快適に動作します。
※ 今回、ZoomLv10より小さいZoomレベルでの範囲で描画をすると、描画するマーカー数が多くなりすぎ、処理が重たくなりすぎたので、クラスター化ON/OFFの切り替えをZoomLv10未満は動作しないようにしています。
もし、体感したい方は、index.htmlの<option value="10">Zoom 10レベル以上で解除</option>のプルダウンのオプションのvalueの値を下げてみることでも、簡単に擬似体験ができます。(MarkerClusterGroupの中で、クラスター化をしないようにするZoomLvを変更することができ、描画範囲にクラスター表示をなくすことができます)

クラスター無しだとこのように一気に重くなる(MarkerClusterの重要性がわかる例)
体感での違い:
-
chunkedLoading: true(デフォルト): 初期表示は0.5秒程度のラグがあるが、その後はスムーズに動作 -
chunkedLoading: false: 初期表示で10秒以上フリーズ、ズーム時に全然応答せずカクつくどころか、操作ができない状態が発生
16,000件でも快適に動作するのは、特に removeOutsideVisibleBounds の効果が大きく、
chunkedLoading も初期描画のカクつきを抑えるのに有効でした。
ブランド別に分けると何が良いのか(結論:視認性と操作性が段違い)
今回は16,000件のマーカーを扱うので、「1つのMarkerClusterGroupに全部入れる」か「ブランド別に分割する」かを検討しました。結論としてはブランド別に分けた方が、視認性・操作性ともに圧倒的に良かったです。
メリット:
- 視認性の向上: クラスターの色がブランドごとに分かれ、密集地域のブランド構成が一目でわかる
- フィルタリングが容易: レイヤー単位でON/OFFできるため、「セブンイレブンだけ表示」が簡単に実装できる
-
パフォーマンスへの影響は軽微: 各MarkerClusterGroupが独立して
removeOutsideVisibleBoundsを適用するため、分けても重くならない
デメリットは「異なるブランドのマーカー同士はクラスタリングされない」ことですが、
今回、ブランドごとのデータの偏り等をみたかったので、MarkerClusterGroupを分けて可視化することで「どのブランドが多いか」を見ることができました。
また、クラスター化されているということは近隣に同一ブランドの店舗が集中しているため、クラスター化されていないブランドがある場合は周辺地域に同一ブランドの店舗が少ない可能性が高い、という見方ができるのも面白いポイントでした。
※ コンビニブランドの全てのデータを抽出できている保証はないので、上記は可能性があるにとどめています。
ブランド別に色分け
各ブランドに色を割り当てて、クラスターもブランド別に色分けしました。
const BRANDS = {
'seven-eleven': { name: 'セブンイレブン', color: '#e60012' }, // 赤
'familymart': { name: 'ファミリーマート', color: '#00a651' }, // 緑
'lawson': { name: 'ローソン', color: '#0078d7' }, // 青
// ...
};
クラスターのスタイルはCSSで定義します。クラス名 .marker-cluster-{type} は、マーカー作成時に iconCreateFunction で自動的に付与されます。
.marker-cluster-seven-eleven {
background-color: rgba(230, 0, 18, 0.6);
}
.marker-cluster-seven-eleven div {
background-color: rgba(230, 0, 18, 0.8);
}
クラスター解除機能の実装
「クラスターを解除して個別のマーカーを見たい」という要望に対応するため、disableClusteringAtZoomオプションを活用しました。
disableClusteringAtZoomとは
指定したズームレベル以上で、クラスタリングが無効になります。
L.markerClusterGroup({
disableClusteringAtZoom: 10 // Zoom10以上で個別表示
});
動的に変更する
ユーザーがセレクトボックスで値を変更したら、MarkerClusterGroupを再構築します。
function rebuildMarkerClusters() {
// 既存のレイヤーを削除
Object.keys(BRANDS).forEach(brandType => {
map.removeLayer(markerClusters[brandType]);
});
// 新しいdisableClusteringAtZoomでMarkerClusterGroupを作成
createMarkerClusterGroups(disableClusteringAtZoom);
// マーカーを再追加
addMarkersToCluster();
}
再構築は少し重い処理ですが、removeOutsideVisibleBoundsが効いているので、実用的な速度で動作します。
なぜ個別マーカー用のレイヤーを作らないのか
最初は「クラスター表示用」と「個別表示用」の2つのレイヤーを作っていましたが、やめました。理由は2つです。
- メモリ効率が悪い – マーカーが2倍になる
-
最適化の恩恵を受けられない – 通常のLayerGroupには
removeOutsideVisibleBoundsのような画面外マーカーの最適化がない
disableClusteringAtZoomを使ってMarkerClusterGroup側で制御する方がパフォーマンスが良いという結論になりました。
セットアップ
Docker環境で動かせます。
git clone https://github.com/megusunu/leaflet-konbini-map.git
cd leaflet-konbini-map
docker compose up --build
http://localhost:8081 で確認できます。
発展:自分のデータで使う
このアプリは 3ステップ で自分のデータを可視化できます。
-
stores.jsonを差し替える – 緯度・経度・カテゴリを含むJSONを用意 -
BRANDSを編集する – カテゴリ名と色を定義 -
docker compose up– 完了
以下、各ステップの詳細です。
データを差し替える
ファイル: app/static/data/stores.json
{
"stores": [
{
"latitude": "緯度",
"longitude": "経度",
"address": "住所(任意)",
"name": "名前",
"type": "カテゴリ"
}
],
"count": 件数
}
typeフィールドの値が、後述するBRANDSオブジェクトのキーと対応します。
カテゴリ名と色を変更する
ファイル: app/static/js/main.js
const BRANDS = {
'cafe': { name: 'カフェ', color: '#8b4513' },
'restaurant': { name: 'レストラン', color: '#ff6347' },
// typeフィールドの値: { name: '表示名', color: 'カラーコード' }
};
JSONデータのtypeとBRANDSのキーを一致させてください。
初期表示位置を変更する
ファイル: app/static/js/main.js の initMap()
// 日本全体を表示(デフォルト)
map = L.map('map').setView([35.6812, 139.7671], 6);
// 例:北海道を中心に表示
map = L.map('map').setView([43.0621, 141.3544], 8);
// 例:大阪を中心に表示
map = L.map('map').setView([34.6937, 135.5023], 10);
第1引数が[緯度, 経度]、第2引数がズームレベル(数字が大きいほど拡大)です。
クラスターの挙動を変更する
ファイル: app/static/js/main.js の createMarkerClusterGroups()
markerClusters[brandType] = L.markerClusterGroup({
maxClusterRadius: 60, // クラスター化する半径(大きいほどまとまりやすい)
disableClusteringAtZoom: 10, // このZoomレベル以上でクラスター解除
spiderfyOnMaxZoom: true, // 最大ズームでスパイダー表示
});
-
maxClusterRadius: 値を大きくすると、より広範囲のマーカーがまとまります -
disableClusteringAtZoom: 値を小さくすると、早い段階でクラスターが解除されます
クラスター解除の選択肢を変更する
ファイル: app/templates/index.html
<select id="cluster-zoom-select">
<option value="none" selected>常にクラスター化</option>
<option value="10">Zoom 10レベル以上で解除</option>
<option value="12">Zoom 12レベル以上で解除</option>
<option value="14">Zoom 14レベル以上で解除</option>
</select>
valueの値がdisableClusteringAtZoomに渡されます。
クラスターの見た目を変更する
ファイル: app/static/css/style.css
/* 例:cafeカテゴリのクラスター色 */
.marker-cluster-cafe {
background-color: rgba(139, 69, 19, 0.6);
}
.marker-cluster-cafe div {
background-color: rgba(139, 69, 19, 0.8);
}
クラス名は .marker-cluster-{type} の形式です。BRANDSのキーと一致させてください。
おわりに
Leaflet + MarkerClusterは、大量のマーカーを扱うときの定番の組み合わせです。
今回のポイントは、
-
removeOutsideVisibleBounds(デフォルトtrue)でパフォーマンス確保 -
disableClusteringAtZoomでクラスター解除を制御 - ブランド別にMarkerClusterGroupを分けて色分け
コンビニ分布を眺めていると、「この地域はファミマが多いな」「セイコーマートは本当に北海道に集中してるな」など、発見があって面白いです。
明日は「WordPress に AI レビュー機能を組み込む──Structured Outputs 実践ガイド」を書きます。
megusunu
🐦 X (@megusunu)
🧶 megusunuLab(ハンドメイド)
🏢 Wells合同会社
Discussion