旅行や出張、あるいは日常の外出先で「喫煙所が見つからない」「地図アプリで検索して行ってみたら、すでに撤去されて更地になっていた」という体験は、多くの喫煙者が一度は直面するストレスです。
このような情報の不透明性と鮮度不足を解決し、ユーザー同士でリアルタイムに正確なスポット情報を投稿・共有できるコミュニティ型位置情報アプリケーションが 「喫煙所サーチ」 です。
本プロダクトの開発においては、Next.js (App Router) のサーバーサイドレンダリング(SSR)環境特有の地図ライブラリ(Leaflet.js)の描画競合問題や、Firebase Firestore の強力なクエリ制約といった技術的なハードルを克服する必要がありました。この記事では、それらの課題を解決した具体的な設計と実装コードを詳しく解説します。
1. プロダクト思想:情報の「鮮度」と「信頼性」を担保する仕組み#
Google Mapなどの汎用地図サービスに対するユーザーの最大の不満は、「喫煙規制の強化に伴う撤去スピードに情報の更新が追いついていない」ことでした。これを解消するために、以下の3つの機能をコアバリューに据えました。
- リアルタイム位置情報検索: 現在地を中心に、半径数百メートル以内にあるスポットを素早く表示。
- ユーザー参加型の更新フロー: 新規のスポット投稿だけでなく、「現在も利用可能か」「撤去されたか」をユーザー投票や報告によってリアルタイムに更新。
- 直感的で迷わない UI: スマホ片手に屋外で歩きながらでも、わずか3タップで正確にピンを刺せる超軽量な操作性。
2. 技術的挑戦①:Firestore のクエリ制約をクリアする位置情報検索設計#
Firebase Firestore には、データベース設計上、「複数の異なるフィールドに対して不等号(範囲判定)クエリを同時に使えない」 という極めて強力な制約があります。
緯度・経度検索におけるジレンマ#
地図上にピンを描画するには、画面の表示範囲(バウンディングボックス)の 最小緯度 <= ターゲット緯度 <= 最大緯度 かつ 最小経度 <= ターゲット経度 <= 最大経度 という、緯度と経度の両方に対する範囲絞り込みクエリが必要になります。しかし、Firestore ではこのクエリを同時に投げることが仕様上不可能です。
解決策:緯度絞り込み + クライアントサイドフィルタリング#
Geohash(ジオハッシュ)などの複雑なインデックスアルゴリズムを導入してシステムを肥大化させるのを避け、開発スピードとパフォーマンスを両立するために、以下のハイブリッドフィルタリングを採用しました。
- データベース(Firestore)側: 最も絞り込み効率の高い「緯度(Latitude)」のみに対して不等号範囲クエリを実行し、データセットを取得。
- クライアント(JavaScript)側:
取得したレコードに対して、もう一方の「経度(Longitude)」が画面の表示範囲内に収まっているかを配列の
filter()で瞬時に判定して描画。
Go/TypeScriptによるフィルタリングの実装コード#
interface SmokingSpot {
id: string;
name: string;
latitude: number;
longitude: number;
status: "active" | "removed";
}
interface BoundingBox {
minLat: number;
maxLat: number;
minLng: number;
maxLng: number;
}
import { collection, query, where, getDocs, Firestore } from "firebase/firestore";
export async function fetchSpotsInView(
db: Firestore,
bounds: BoundingBox
): Promise<SmokingSpot[]> {
// 1. データベース側では「緯度(latitude)」の範囲指定クエリのみを実行
const spotsRef = collection(db, "spots");
const latQuery = query(
spotsRef,
where("latitude", ">=", bounds.minLat),
where("latitude", "<=", bounds.maxLat)
);
const querySnapshot = await getDocs(latQuery);
const fetchedSpots: SmokingSpot[] = [];
querySnapshot.forEach((doc) => {
const data = doc.data();
fetchedSpots.push({
id: doc.id,
name: data.name,
latitude: data.latitude,
longitude: data.longitude,
status: data.status,
});
});
// 2. クライアント側で「経度(longitude)」がバウンディングボックス内かを追加判定(フィルタリング)
return fetchedSpots.filter(
(spot) =>
spot.longitude >= bounds.minLng &&
spot.longitude <= bounds.maxLng &&
spot.status === "active" // 撤去済みのアクティブ除外
);
}この設計により、サーバーサイドへの負荷と通信帯域を最小限に抑えつつ、インデックスの複雑化を防ぎ、ミリ秒単位での超高速なマップ描画を実現しました。
3. 技術的挑戦②:Next.js (SSR) 環境下における Leaflet.js の描画エラー対策#
位置情報マップの描画にはオープンソースの地図ライブラリである Leaflet.js (および react-leaflet)を使用しました。しかし、Leaflet はブラウザ環境特有のグローバルオブジェクト(window や document)に強く依存して動作します。
そのため、Next.js のサーバーサイドレンダリング(SSR)エンジンがサーバー上でこのコンポーネントを事前レンダリングしようとすると、ReferenceError: window is not defined という重大なエラーを吐いてクラッシュしてしまいます。
解決策:Dynamic Import(動的読み込み)による遅延ローディング#
このSSRエラーを回避するため、地図を描画するコアコンポーネントを dynamic 関数を用いてブラウザ上でのみインポートするように制御します。
Next.jsでの安全な地図読み込み実装#
import dynamic from 'next/dynamic';
import React from 'react';
// 地図コンポーネントをサーバーサイドレンダリング(SSR)対象外として動的にロードする
const MapBridge = dynamic(
() => import('./MapComponent').then((mod) => mod.MapComponent),
{
ssr: false, // サーバー側でのプリレンダリングを完全に無効化
loading: () => (
// 地図が読み込まれるまでの美しい骨組み(プレースホルダー)を表示
<div className="w-full h-full bg-slate-100 flex items-center justify-center animate-pulse">
<span className="text-sm text-slate-400 font-medium">地図を初期化中...</span>
</div>
),
}
);
export const MapPage: React.FC = () => {
return (
<div className="w-full h-screen">
<MapBridge />
</div>
);
};4. モバイルUXの最適化:「固定中央ピン」方式の採用#
開発初期のプロトタイプでは、一般的な「画面の地図上の任意の場所をタップした位置にピンを立てる」というUIを採用していました。
しかし、スマートフォンの小さな画面においては、「ピンを刺そうとタップした瞬間に自分の指先で地図が隠れてしまい、10メートル単位の正確なピン刺しが非常に困難である」 という操作上の重大なUX問題が判明しました。
解決策:タクシー配車アプリ式「固定センターピン」への移行#
この問題をクリアするため、UI設計を「地図のド真ん中にピンアイコンを動かさず固定しておき、ユーザーが地図側をスワイプ(ドラッグ)してターゲット地点を中央に合わせる」という方式(Uberやタクシーアプリ、デリバリーアプリで標準採用されているもの)に変更しました。
これにより、指で画面が遮られることなく、十字線の入ったマーカーに対してピンポイントかつノンストレスで座標(緯度経度)を決定できる、極めて高いモバイルUXが完成しました。
5. セキュリティ設計:悪意ある投稿やスパムへの防衛策#
誰でも自由にスポットを追加できるオープンなアプリにおいて、デタラメなデータの乱造や荒らし行為への防御壁(防衛策)は必須です。
- Firebase Security Rules による書き込みバリデーション: 匿名ログイン(Anonymous Auth)を含む、認証済みのユーザーのみ書き込みを許可し、かつ「緯度と経度が数値の範囲内に収まっているか」「投稿文字列が20文字以内か」といったスキーマ構造をセキュリティルールレベルで厳格に検証。
- モデレーションステート(承認制)の導入:
新規に投稿されたスポットは、データベース上で
approved: false(未承認)のフラグを持って作成されます。管理者の検証ダッシュボードからtrueに承認されたスポットのみが、一般ユーザーのフロントエンド地図上にピンとして一括マッピングされる安全なモデレーションワークフローを構築しました。
6. よくある質問(FAQ)#
Q. Leaflet ではなく Google Maps API を採用しなかった理由は?#
A. 最大の理由は「APIの利用料金(ランニングコスト)」です。Google Maps API は一定回数以上の地図ロードに対して従量課金が発生し、個人開発でバズった場合に意図しない高額請求が発生するリスクがありました。一方、Leaflet.js は完全無料のオープンソースであり、国土地理院や OpenStreetMap などの無料のタイルレイヤーと組み合わせることで、地図表示にかかるコストを完全ゼロ円で運用できるため、スモールスタートに最適でした。
Q. 半径100m以内の「近い順」にスポットをソートして表示できますか?#
A. はい、クライアントサイドで現在地(緯度経度)と各スポットの座標間の直線距離を「ハバーシンの公式(Haversine formula)」を用いて計算し、算出した距離をもとに JavaScript の sort() を実行することで、簡単に近い順のリスト表示を実装できます。
Q. Firestore で緯度の不等号判定を行った際、取得できるデータ件数が多くなりすぎて通信量が増えませんか?#
A. 一般的な運用において、地図のズームレベル(拡大率)に応じて検索ボックスの範囲を制限するため、一度に取得するデータは高々数十〜数百件程度に収まります。さらに件数が増大した場合は、表示範囲の緯度の差分(delta)が一定以上(広域ズーム時)になった場合に検索クエリの実行を一時的に制限し、「地図を拡大して検索してください」というUIを表示することで、無駄なデータ読み込みによるFirestoreの読み取り枠(Read Count)消費を防ぐ設計が実務上有効です。











