MechaToraのブログ

記事のアイキャッチ画像
技術・開発

宝くじ高額当選売場マップの開発 - 地図アプリ実装の実践

「高額当選が出やすい宝くじ売場はどこだろう?」——そんな疑問から始まったのが、「宝くじ高額当選売場マップ」の開発プロジェクトです。全国の宝くじ売場データをGoogleマップ風に可視化し、ユーザーが近くの当選実績がある売場を簡単に探せるツールを作りました。

この記事では、Leaflet.jsを使った地図アプリの実装、300件以上の売場データの処理、マーカークラスタリング、Firebase Hostingでのデプロイまで、開発の全プロセスを詳しく解説します。地図アプリを作りたい方の参考になれば幸いです。

プロジェクトの概要

開発の動機

きっかけは、年末ジャンボ宝くじを買う前に「どこで買うのがいいか?」と調べたことでした。宝くじ公式サイトには高額当選売場のリストはあるものの、テキストの羅列で非常に見にくい。地図上で一目で分かるツールがあれば便利だと思い、開発を決意しました。

主な機能

技術スタック

なぜLeaflet.jsを選んだか

セクション画像

Google Maps API との比較

項目 Google Maps API Leaflet.js
料金 月28,000読み込みまで無料
超過で従量課金
完全無料
導入 APIキー必要 CDNで即利用可能
カスタマイズ ★★★☆☆ ★★★★★
軽量性 ★★☆☆☆ ★★★★★
機能 ★★★★★ ★★★★☆

個人開発で無料運用を重視したため、Leaflet.jsを選択しました。

Leaflet.jsのメリット

開発ステップ1:基本的な地図の表示

Leaflet.jsの導入

CDN経由で簡単に導入できます:

<!DOCTYPE html>
<html>
<head>
    <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
    <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
    <style>
        #map {
            width: 100%;
            height: 600px;
        }
    </style>
</head>
<body>
    <div id="map"></div>
</body>
</html>

地図の初期化

// 地図を初期化(東京駅を中心に表示)
const map = L.map('map').setView([35.6812, 139.7671], 13);

// タイル層を追加(OpenStreetMap)
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
    attribution: '© OpenStreetMap contributors',
    maxZoom: 19
}).addTo(map);

これだけで、インタラクティブな地図が表示されます!

マーカーの追加

// 西銀座チャンスセンターにマーカーを追加
const marker = L.marker([35.6717, 139.7643]).addTo(map);

// ポップアップを追加
marker.bindPopup('<b>西銀座チャンスセンター</b><br>高額当選多数!');

開発ステップ2:データの準備と処理

セクション画像

データ収集

宝くじ公式サイトから高額当選売場のデータをスクレイピング...と思いましたが、利用規約でNGだったため、手動でCSVファイルを作成しました。

CSVファイル例(lottery_shops.csv)

name,address,prefecture,lat,lng,wins,notes
西銀座チャンスセンター,東京都中央区銀座4-1,東京都,35.6717,139.7643,523,1等最多
大阪駅前第4ビル特設売場,大阪府大阪市北区梅田1-11-4,大阪府,34.7024,135.4959,312,近畿最多
名駅前チャンスセンター,愛知県名古屋市中村区名駅1-1-4,愛知県,35.1706,136.8816,187,中部最多

CSVをJSONに変換

Excelで編集しやすいCSVで管理し、JavaScriptでJSONに変換:

// Papa Parse ライブラリを使用(CDN経由)
// <script src="https://cdn.jsdelivr.net/npm/papaparse@5.4.1/papaparse.min.js"></script>

Papa.parse('lottery_shops.csv', {
    download: true,
    header: true,
    complete: function(results) {
        const shops = results.data;
        addMarkersToMap(shops);
    }
});

複数マーカーの追加

function addMarkersToMap(shops) {
    shops.forEach(shop => {
        // 座標が有効な場合のみマーカー追加
        if (shop.lat && shop.lng) {
            const marker = L.marker([parseFloat(shop.lat), parseFloat(shop.lng)])
                .addTo(map);

            // ポップアップ内容
            const popupContent = `
                <div class="shop-popup">
                    <h3>${shop.name}</h3>
                    <p><strong>住所:</strong> ${shop.address}</p>
                    <p><strong>高額当選:</strong> ${shop.wins}回</p>
                    <p>${shop.notes}</p>
                </div>
            `;

            marker.bindPopup(popupContent);
        }
    });
}

開発ステップ3:マーカークラスタリング

問題:マーカーが多すぎて見づらい

300個以上のマーカーを全て表示すると、地図がマーカーだらけで非常に見づらくなりました。特に東京都内は売場が密集しており、マーカーが重なって個別にクリックできません。

解決策:Leaflet.markercluster

ズームレベルに応じて、近接するマーカーを自動的にまとめるプラグインを導入:

<!-- CDN でクラスタリングプラグインを読み込み -->
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.css" />
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.Default.css" />
<script src="https://unpkg.com/leaflet.markercluster@1.5.3/dist/leaflet.markercluster.js"></script>

クラスタリングの実装

// マーカークラスターグループを作成
const markers = L.markerClusterGroup({
    // クラスターアイコンのカスタマイズ
    iconCreateFunction: function(cluster) {
        const count = cluster.getChildCount();
        let size = 'small';
        if (count > 100) size = 'large';
        else if (count > 20) size = 'medium';

        return L.divIcon({
            html: `<div><span>${count}</span></div>`,
            className: 'marker-cluster marker-cluster-' + size,
            iconSize: L.point(40, 40)
        });
    },
    // 最大ズームレベル(これ以上拡大するとクラスター解除)
    disableClusteringAtZoom: 18
});

// マーカーをクラスターグループに追加
shops.forEach(shop => {
    if (shop.lat && shop.lng) {
        const marker = L.marker([parseFloat(shop.lat), parseFloat(shop.lng)]);
        marker.bindPopup(createPopupContent(shop));
        markers.addLayer(marker);
    }
});

// クラスターグループを地図に追加
map.addLayer(markers);

クラスターアイコンのスタイリング

.marker-cluster {
    background-color: rgba(37, 99, 235, 0.6);
    border-radius: 50%;
    text-align: center;
    color: white;
    font-weight: bold;
}

.marker-cluster-small {
    width: 40px;
    height: 40px;
}

.marker-cluster-medium {
    width: 50px;
    height: 50px;
    background-color: rgba(234, 88, 12, 0.6);
}

.marker-cluster-large {
    width: 60px;
    height: 60px;
    background-color: rgba(220, 38, 38, 0.6);
}

これにより、ズームアウト時は「東京都: 85件」のようにまとめて表示され、ズームインすると個別のマーカーが表示されるようになりました。

開発ステップ4:検索とフィルター機能

都道府県別フィルター

// HTMLセレクトボックス
<select id="prefecture-filter">
    <option value="">全国</option>
    <option value="東京都">東京都</option>
    <option value="大阪府">大阪府</option>
    <!-- ... その他の都道府県 -->
</select>

// JavaScript
document.getElementById('prefecture-filter').addEventListener('change', (e) => {
    const selectedPref = e.target.value;

    // 既存のマーカーをクリア
    markers.clearLayers();

    // フィルタリングして再追加
    const filteredShops = selectedPref
        ? shops.filter(shop => shop.prefecture === selectedPref)
        : shops;

    filteredShops.forEach(shop => {
        if (shop.lat && shop.lng) {
            const marker = L.marker([parseFloat(shop.lat), parseFloat(shop.lng)]);
            marker.bindPopup(createPopupContent(shop));
            markers.addLayer(marker);
        }
    });

    // 選択された地域に地図を移動
    if (selectedPref && filteredShops.length > 0) {
        const bounds = L.latLngBounds(
            filteredShops.map(shop => [parseFloat(shop.lat), parseFloat(shop.lng)])
        );
        map.fitBounds(bounds);
    }
});

現在地から最寄りの売場を探す

document.getElementById('find-nearby').addEventListener('click', () => {
    if (!navigator.geolocation) {
        alert('お使いのブラウザは位置情報をサポートしていません');
        return;
    }

    navigator.geolocation.getCurrentPosition(
        (position) => {
            const userLat = position.coords.latitude;
            const userLng = position.coords.longitude;

            // ユーザーの位置にマーカー追加
            L.marker([userLat, userLng], {
                icon: L.icon({
                    iconUrl: 'https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-2x-blue.png',
                    iconSize: [25, 41],
                    iconAnchor: [12, 41]
                })
            }).addTo(map).bindPopup('現在地').openPopup();

            // 地図を現在地に移動
            map.setView([userLat, userLng], 14);

            // 最寄りの売場を計算
            const nearest = findNearestShop(userLat, userLng, shops);
            if (nearest) {
                alert(`最寄りの高額当選売場: ${nearest.name}(約${nearest.distance.toFixed(1)}km)`);
            }
        },
        (error) => {
            alert('位置情報の取得に失敗しました');
        }
    );
});

function findNearestShop(userLat, userLng, shops) {
    let nearestShop = null;
    let minDistance = Infinity;

    shops.forEach(shop => {
        if (shop.lat && shop.lng) {
            const distance = calculateDistance(
                userLat, userLng,
                parseFloat(shop.lat), parseFloat(shop.lng)
            );

            if (distance < minDistance) {
                minDistance = distance;
                nearestShop = { ...shop, distance };
            }
        }
    });

    return nearestShop;
}

// Haversine公式で2点間の距離を計算(km)
function calculateDistance(lat1, lon1, lat2, lon2) {
    const R = 6371; // 地球の半径(km)
    const dLat = (lat2 - lat1) * Math.PI / 180;
    const dLon = (lon2 - lon1) * Math.PI / 180;

    const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
              Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
              Math.sin(dLon / 2) * Math.sin(dLon / 2);

    const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
    return R * c;
}

開発ステップ5:Firebase Hostingでデプロイ

Firebase Hostingを選んだ理由

デプロイ手順

# 1. Firebase CLIをインストール
npm install -g firebase-tools

# 2. Firebaseにログイン
firebase login

# 3. プロジェクトを初期化
firebase init hosting

# 質問に回答:
# - Use an existing project → 既存プロジェクトを選択(または新規作成)
# - What do you want to use as your public directory? → public
# - Configure as a single-page app? → No
# - Set up automatic builds and deploys with GitHub? → No

# 4. HTMLファイルをpublicフォルダに配置

# 5. デプロイ
firebase deploy

# デプロイ完了! https://your-project.web.app/ でアクセス可能

苦労した点と解決策

1. 緯度経度の取得

問題:売場の住所から緯度経度を手動で調べるのが大変(300件以上)

解決策:Google Geocoding APIで一括変換

// Node.jsスクリプトで一括変換
const axios = require('axios');
const fs = require('fs');

async function geocodeAddress(address) {
    const API_KEY = 'YOUR_GOOGLE_API_KEY';
    const url = `https://maps.googleapis.com/maps/api/geocode/json?address=${encodeURIComponent(address)}&key=${API_KEY}`;

    const response = await axios.get(url);
    if (response.data.results.length > 0) {
        const location = response.data.results[0].geometry.location;
        return { lat: location.lat, lng: location.lng };
    }
    return null;
}

// CSVファイルを読み込んで緯度経度を追加
// (実装は省略)

2. モバイル対応

問題:スマホで見るとマーカーが小さくてタップしにくい

解決策:レスポンシブデザイン + タッチ最適化

/* モバイル対応 */
@media (max-width: 768px) {
    #map {
        height: 400px; /* PC: 600px → スマホ: 400px */
    }

    .leaflet-popup-content {
        font-size: 14px;
        max-width: 250px;
    }

    /* マーカーを少し大きく */
    .leaflet-marker-icon {
        transform: scale(1.2);
    }
}

3. 読み込み速度

問題:300件のマーカーを一度に読み込むと遅い

解決策:遅延読み込み + クラスタリング

まとめ

開発で学んだこと

  1. Leaflet.jsは個人開発に最適:無料、軽量、簡単
  2. クラスタリングは必須:100件以上のマーカーには必須機能
  3. データ整備が重要:緯度経度の精度がUXを左右する
  4. Firebase Hostingは便利:無料でHTTPS、CDN、自動デプロイ

今後の改善予定

地図アプリは、データさえあれば比較的簡単に作れます。Leaflet.jsを使えば、Google Maps APIのようなコストを気にせず、自由にカスタマイズできます。ぜひ、あなたも何か地図アプリを作ってみてください!