はじめに
「今日のランチどうしよう…」と毎日悩んでいませんか?
そんな悩みを解決するために、現在地周辺のレストランをランダムに3軒ピックアップしてルーレット風に表示するWebアプリを作りました。
使ってみてよければ、いいねやコメントたくさん下さい!!
デモ: https://arupaka610.github.io/lunch-roulette/
作ったもの
機能一覧
| 機能 | 内容 |
|---|---|
| 📍 現在地取得 | ブラウザのGeolocation APIでGPS取得 |
| 🔍 範囲指定検索 | 300m / 500m / 1km / 2km から選択 |
| 🍜 ジャンルフィルター | 和食・ラーメン・寿司・中華など12種 |
| 💴 予算フィルター | ¥〜¥¥¥¥ の4段階 |
| 🎲 ルーレット演出 | 3枚のカードが順番にアニメーション表示 |
| 🗺️ Google マップ連携 | 選んだ店をそのままマップで開ける |
| 💬 口コミ表示 | 最新3件の口コミを表示 |
画面構成
① 検索画面 → ② ルーレット画面 → ③ 詳細画面
(条件設定) (3店舗が順番に出現) (店名・評価・口コミ・マップ)
技術スタック
- フロントエンド: HTML / CSS / Vanilla JavaScript(フレームワークなし)
- API: Google Maps JavaScript API + Places API (New)
- ホスティング: GitHub Pages
- CI/CD: GitHub Actions
- 広告: Google AdSense
フレームワークを使わずに作ったので、ファイル3つ(index.html / style.css / app.js)のシンプルな構成です。
ポイント①:Google Places API (New) を使う
旧APIと新APIの違い
今回は旧Places APIではなく Places API (New) を使いました。
| 比較 | 旧API | 新API (New) |
|---|---|---|
| エンドポイント | maps.googleapis.com/maps/api/place/... |
places.googleapis.com/v1/... |
| JSでの呼び出し | PlacesService.nearbySearch() |
Place.searchNearby() |
| 非同期 | コールバック | async/await |
新APIは async/await で書けてスッキリします。
検索の実装
// ジャンルなし → searchNearby
const { places } = await google.maps.places.Place.searchNearby({
fields: ['id', 'displayName', 'rating', 'priceLevel', 'regularOpeningHours'],
locationRestriction: {
center: new google.maps.LatLng(lat, lng), // LatLngオブジェクトが必要
radius: 500,
},
includedPrimaryTypes: ['restaurant'],
maxResultCount: 20,
});
// ジャンルあり → searchByText(locationBias を使う)
const { places } = await google.maps.places.Place.searchByText({
textQuery: `${genre} 飲食店`,
fields: ['id', 'displayName', 'rating', 'priceLevel', 'regularOpeningHours'],
locationBias: {
center: new google.maps.LatLng(lat, lng),
radius: 500,
},
maxResultCount: 20,
});
ハマりポイント①:locationRestriction vs locationBias
searchNearby は locationRestriction で円形指定できますが、searchByText の locationRestriction は矩形(LatLngBounds)しか受け付けません。
円形で絞り込みたい場合は locationBias を使う必要があります(ただし厳密な制限ではなくバイアスになります)。
ハマりポイント②:center は LatLng オブジェクトが必要
{ lat: 35.68, lng: 139.76 } のようなプレーンオブジェクトを渡すと InvalidValueError になります。
// ❌ エラーになる
center: { lat: 35.68, lng: 139.76 }
// ✅ 正しい
center: new google.maps.LatLng(35.68, 139.76)
ハマりポイント③:isOpen() が関数じゃない場合がある
検索結果の regularOpeningHours に isOpen が関数として存在しないケースがありました。openNow プロパティをフォールバックとして使います。
getOpenNow(oh) {
if (!oh) return null;
try {
if (typeof oh.isOpen === 'function') return oh.isOpen();
if (oh.openNow != null) return oh.openNow;
} catch {}
return null;
},
ポイント②:APIキーをGitHub Secretsで管理
Vanilla JSのみの静的サイトなのでサーバーがなく、APIキーをどう扱うかが課題でした。
解決策:GitHub Actionsでビルド時に注入
config.js にはプレースホルダーを置いておき、デプロイ時に sed で置換します。
# .github/workflows/deploy.yml
- name: Inject API Keys
run: |
sed -i "s|GOOGLE_MAPS_API_KEY_PLACEHOLDER|${{ secrets.GOOGLE_MAPS_API_KEY }}|g" js/config.js
sed -i "s|ADSENSE_CLIENT_PLACEHOLDER|${{ secrets.ADSENSE_CLIENT }}|g" index.html
sed -i "s|ADSENSE_SLOT_PLACEHOLDER|${{ secrets.ADSENSE_SLOT }}|g" index.html
これでコードにAPIキーを含めずに済みます。あわせてGoogle Cloud ConsoleでAPIキーのHTTPリファラー制限を設定して、自分のドメイン以外からの使用をブロックしています。
ポイント③:iOS Safari の位置情報対応
iOSではユーザーの操作なしに getCurrentPosition() を呼ぶとブロックされることがあるため、位置情報エリア全体をタップ可能なボタンにしました。
<div class="location-row" id="location-row" role="button" tabindex="0">
<div id="location-dot" class="location-dot"></div>
<span id="location-status-text">📍 タップして現在地を取得</span>
</div>
document.getElementById('location-row')
.addEventListener('click', () => this.getLocation());
タップ(ユーザーのジェスチャー)の中で getCurrentPosition() を呼ぶことで、iOSでも正常に動作します。
ポイント④:AdSenseを display:none の要素に適用しない
AdSenseの初期化を非表示の画面に対して行うと No slot size for availableWidth=0 エラーが発生します。
showScreen() 内で、画面を表示してから100ms後に広告を初期化することで解決しました。
showScreen(name) {
document.querySelectorAll('.screen').forEach(s => s.classList.remove('active'));
document.getElementById(`${name}-screen`).classList.add('active');
// 表示後に広告を初期化
setTimeout(() => {
const screen = document.getElementById(`${name}-screen`);
screen.querySelectorAll('ins.adsbygoogle:not([data-adsbygoogle-status])').forEach(() => {
try { (adsbygoogle = window.adsbygoogle || []).push({}); } catch (e) {}
});
}, 100);
},
ポイント⑤:ルーレットアニメーション
3枚のカードが順番に「スロットマシン風」に出現します。
spinCard(index, restaurant) {
const card = document.getElementById(`roulette-card-${index}`);
const allNames = this.searchResults.map(r => r.displayName);
card.className = 'roulette-card spinning';
// 高速で店名を切り替え(スロットマシン演出)
const interval = setInterval(() => {
const fake = allNames[Math.floor(Math.random() * allNames.length)];
card.querySelector('.roulette-card-content').innerHTML =
`<div class="card-name" style="opacity:0.6">${fake}</div>`;
}, 120);
// 1.2秒後に停止して本当の店名を表示
setTimeout(() => {
clearInterval(interval);
card.className = 'roulette-card revealed clickable';
card.querySelector('.roulette-card-content').innerHTML = this.buildCardHTML(restaurant);
card.onclick = () => this.selectCard(index, restaurant, card);
}, 1200);
},
検索結果の全店名を「フェイク候補」として使うので、実際に近くにある店名が流れるのがリアルで面白いです。
デプロイ方法
必要なもの
- Google Cloud Console でAPIキーを取得(Maps JavaScript API + Places API (New) を有効化)
- GitHub リポジトリを作成
- GitHub Secrets に以下を登録:
| Secret名 | 値 |
|---|---|
GOOGLE_MAPS_API_KEY |
Google Maps APIキー |
ADSENSE_CLIENT |
ca-pub-XXXXXXXXXX(AdSense申請後) |
ADSENSE_SLOT |
広告ユニットID(AdSense申請後) |
- Settings → Pages → Source: GitHub Actions
pushするだけで自動デプロイ
git push origin main
# → GitHub Actions が自動でAPIキーを注入してデプロイ
まとめ
| 課題 | 解決策 |
|---|---|
| APIキーをコードに含めたくない | GitHub Actions + Secrets で注入 |
| iOS Safariで位置情報が拒否される | タップ操作で getCurrentPosition() を呼ぶ |
isOpen() が動かない |
openNow プロパティをフォールバック利用 |
| AdSenseが非表示要素でエラー |
showScreen() 後に100msディレイして初期化 |
searchByText で locationRestriction エラー |
locationBias を使う |
フレームワークなしの素のJavaScriptでも、Google Places APIを使えば実用的なアプリが作れました。
ぜひ試してみてください!→ https://arupaka610.github.io/lunch-roulette/
