🗾
React × Supabase (PostGIS) で作る「聖地マップ」開発ログ

はじめに
アニメやドラマの「聖地(ロケ地)」を地図上で探索できるWebアプリ、SeichiMapを開発しました!
- Google Mapsにはない「文脈」を探したい
- Google Mapsで「コンビニ」を探すのは簡単ですが、「あのドラマの告白シーンで使われたベンチ」を探すのは困難。
- 「この辺に何かないかな?」と地図を眺めるだけで、物語の舞台が浮かび上がってくるような体験を作りたくて開発を始めています。
技術スタック (v3.0)
- Frontend: Vite, React 18, TypeScript, Tailwind CSS
- Map Library: React Leaflet + Leaflet
- Backend: Supabase (PostgreSQL)
- Database Extension: PostGIS
技術的な課題:大量のデータ vs パフォーマンス
- 聖地データは全国に数万件あります。これらを全てフロントエンドで一度に取得(全件fetch)し、クライアントサイドでフィルタリングするのは以下の理由でNGです。
- 初期ロードが重すぎる: 数MBのJSONをパースするのはモバイル端末に厳しい。
- 無駄な通信: ユーザーは「今見ている東京の地図」の情報だけ欲しいのに、沖縄のデータまでDLすることになる。
- 解決策として、「現在表示されている地図の範囲内」にあるデータだけをDBから取得する、いわゆるサーバーサイドフィルタリングを実装する必要があります。
バックエンド:SupabaseとPostGISの活用
SupabaseはPostgreSQLベースなので、地理空間情報のデファクトスタンダードである PostGIS がそのまま使えます。
PostGISの有効化
SupabaseのDashboardから Database -> Extensions に進み、postgis を有効化します。これだけで geography 型や ST_DWithin などの関数が使えるようになります。
Schema設計
- 緯度経度 (lat, lng) カラムとは別に、PostGIS用の
geography型カラムを作成します。
alter table seichi_spots
add column location geography(Point);
RPC (Stored Function) の実装
- クライアントから「地図の四隅の座標(北東、南西)」を受け取り、その範囲内のスポットだけを返す関数 spots_in_bounds を定義します。Supabase JS Client からは
rpc()メソッドで簡単に呼び出せます。
create or replace function spots_in_bounds(
min_lat float,
min_lng float,
max_lat float,
max_lng float
)
returns setof seichi_spots
language sql
as $$
select *
from seichi_spots
where location && ST_MakeEnvelope(min_lng, min_lat, max_lng, max_lat, 4326);
$$;
フロントエンド:React Leafletとの連携
次に、地図の移動に合わせてこのAPIを叩く処理を実装します。
export const searchSpotsInBounds = async (bounds: MapBounds) => {
const { data, error } = await supabase
.rpc('spots_in_bounds', {
min_lat: bounds.southWest.lat,
min_lng: bounds.southWest.lng,
max_lat: bounds.northEast.lat,
max_lng: bounds.northEast.lng
});
if (error) throw error;
// DBの行データをフロントエンドの型に変換して返す
return { sites: (data || []).map(mapDbRowToSite) };
};
地図移動イベントのハンドリング
-
react-leafletのuseMapEventsを使い、moveend(移動終了)イベントをフックします。しかし、わずかに動かすたびにAPIを叩くとリクエスト過多になるため、Debounce処理と移動距離の判定を入れています。
// MapControllerコンポーネント内
useMapEvents({
moveend: () => {
// 検索モード時は自動ロードしない
if (isSearchModeRef.current) return;
// Debounce: 500ms待ってから処理
if (moveDebounceTimerRef.current) clearTimeout(moveDebounceTimerRef.current);
moveDebounceTimerRef.current = setTimeout(() => {
const c = map.getCenter();
const z = map.getZoom();
const b = map.getBounds();
// ★最適化: "Significant Move" の判定
// ズームが変わった、または画面の5%以上移動した場合のみFetch
const shouldFetch = checkSignificantMove(c, z, b);
if (shouldFetch) {
onBoundsChange({
southWest: { lat: b.getSouth(), lng: b.getWest() },
northEast: { lat: b.getNorth(), lng: b.getEast() }
});
}
}, 500);
}
});
マーカーのクラスタリング
- 大量のピンが表示されるとUIが崩壊するため、
react-leaflet-clusterを導入しました。 - chunkedLoading オプションを有効にすることで、数千件のピンがあってもブラウザがフリーズしません。
<MarkerClusterGroup
chunkedLoading
iconCreateFunction={createClusterCustomIcon}
maxClusterRadius={60}
spiderfyOnMaxZoom={true} // 最大ズーム時はクリックでピンが広がるように
>
{markers}
</MarkerClusterGroup>
パフォーマンス改善ポイント
- データ取得の抑制 (Debounce & Throttling)
- 前述の通り、
moveendイベントを間引くことで、SupabaseへのAPIコール数を減らしています。無料枠のあるPaasを使う個人開発では、こうした「リクエスト節約」は死活問題です。
- 前述の通り、
- アイコン管理 (Lucide React & Emoji)
- 当初は画像アイコンを検討していましたが、リクエスト数が増えるため廃止しました。
- 現在は
L.divIconを使い、HTMLとしてレンダリングしています。 - 絵文字については、Gemini に作品に合った絵文字を選定させてDBに保存(例: スラムダンク→🏀)
// L.divIcon の html プロパティに埋め込む
const createCustomIcon = (emoji: string, workTitle: string, isSelected: boolean) => {
return L.divIcon({
className: 'custom-marker-pin',
html: `
<div class="marker-pin ...">
<span>${emoji}</span>
</div>
<div class="marker-label ...">${workTitle}</div>
`,
// ...
});
};
まとめ
- React + Leaflet + Supabase (PostGIS) の構成は、個人開発で位置情報アプリを作る上で非常にバランスが良いと感じました…!
- 実際にどんな挙動になっているかはサービスを触ってみていただけると!
データ収集に使ったGemini活用の知見などは、別のZennの記事でも紹介していますので、興味があればぜひ。
Discussion