📍 第十二章:Leaflet.js カスタム高度マーカーとクラスタリング最適化
前章のCrewAIクローラー実戦で、台湾全土のキャンプ場データ(名称、価格、緯度経度)を数千件収集し、Supabaseデータベースに保存することに成功しました。
しかし今、フロントエンド開発における重大な課題に直面しています。 もしLeafletのデフォルト構文で5000ものポイントを一度に地図上にプロットすると:
- 視覚的災害:5000個の同じ青い水滴マーカーが画面を埋め尽くし、地図のベースマップが見えなくなり、どのキャンプ場が安いか判別不能になります。
- パフォーマンス災害:Leafletはデフォルトで各マーカーにDOM要素(divやimg)を生成します。5000個のDOMはモバイル端末のメモリを一瞬で消費し、地図操作を極端に重くするか、ブラウザをクラッシュさせます。
Airbnbの地図を見ると、彼らのマーカーは単なるアイコンではなく「価格」が直接表示されています。地図を縮小すると、これらの価格は「このエリアに150物件」と表示される円に融合します。 この上級実戦講座では、Vibe Codingの手法でAIを指揮し、AirbnbレベルのカスタムHTMLマーカーとマーカークラスタリングシステムを実装します!
🎨 実戦1:青い水滴を捨て、Airbnb風価格タグを作成
ネイティブのLeaflet L.markerでは画像の変更しかできません。しかし「NT$ 1200」のような動的な価格を表示する場合、数字を含む数千枚の画像を生成することは現実的ではありません。
Leafletは強力なツールL.divIconを提供しています。これを使えば、実際のHTMLとTailwind CSSを地図座標上に直接埋め込めます!
💡 Vibe Prompt 実戦1:AIに高品質なDivIconを作成させる
このPromptは難易度が非常に高いです。React、Leaflet、Tailwindスタイルを組み合わせる必要があります。
[!IMPORTANT] 以下のPromptをAIにコピーして送信:
React-Leafletを使用して地図を開発中です。デフォルトの青い水滴アイコンを、Airbnbのような価格表示タグに変更したいです。createPriceIcon(price, isHot)関数を作成してください。この関数はL.divIconインスタンスを返します。要件:1. className: 'custom-div-icon'を使用し、html属性に文字列形式のHTMLを設定2. HTML構造:角丸(rounded-full)の白色カプセル背景に「NT$ 価格」を表示3. Tailwindスタイル:bg-white, text-zinc-900, font-bold, px-3, py-1, shadow-lg, border, border-zinc-2004. 高度なロジック:isHotがtrue(人気キャンプ場)の場合、背景をbg-rose-500、文字をtext-whiteに変更し、animate-bounceで軽く跳ねるアニメーションを追加完全なJavaScriptコードと、L.divIconの使い方に関する詳細な日本語解説を提供してください
🤖 AIが生成したミリオンレベルのインターフェースアイコン:
import L from 'leaflet';
/**
* Airbnbスタイルのカスタム価格マーカーを作成
* @param {number} price - キャンプ場の価格
* @param {boolean} isHot - 人気キャンプ場かどうか
* @returns {L.DivIcon} Leaflet DivIconインスタンス
*/
export const createPriceIcon = (price, isHot) => {
// 人気度に基づいてTailwindのClassNameを動的に決定
const baseClass = "font-bold px-3 py-1 rounded-full shadow-lg border text-sm whitespace-nowrap transition-transform hover:scale-110";
const colorClass = isHot
? "bg-rose-500 text-white border-rose-600 animate-bounce" // 人気:赤色で跳ねる
: "bg-white text-zinc-900 border-zinc-200"; // 通常:白色でシンプル
// Tailwindスタイルを組み合わせた純粋なHTML文字列
const htmlContent = `
<div class="${baseClass} ${colorClass}">
NT$ ${price.toLocaleString()}
</div>
`;
// L.divIconを使用!これはLeafletの最も強力な機能の1つで、任意のHTMLをレンダリング可能
return L.divIcon({
html: htmlContent,
className: '', // ⚠️ 重要な注意点:空文字列に設定しないと、Leafletがデフォルトの白い正方形背景を追加
iconSize: [null, null], // nullを設定するとHTMLコンテンツが自動的にサイズ決定
iconAnchor: [30, 15], // アンカーポイント:座標がこのカプセルのどの位置に対応するか(通常は中心やや下)
});
};
🔍 詳細解説:
これはウェブエンジニアの秘密兵器です。文字列テンプレート`を巧妙に使用してTailwind CSSを組み合わせています。
最も注意が必要なのはclassName: ''です。これを設定しないと、Leafletが親切にもデフォルトCSSを追加し、美しいカプセルの外側に醜い白い四角枠が表示されてしまいます。AIはこの落とし穴を完璧に回避してくれました!
🗺️ 実戦2:カスタムタグをReact-Leafletループにバインド
カプセル生成機が完成したので、データベースから取得したデータに一つずつ適用していきます。
💡 Vibe Prompt 実戦2:MarkerにカスタムIconを設定
[!IMPORTANT] 以下のPromptをAIにコピーして送信:
現在のReactコンポーネント<CampingMap>には、Supabaseから取得したcamps配列があります。React-Leafletの<MapContainer>と<TileLayer>を使用して地図をレンダリングしてください。camps.mapループを使用して、すべての<Marker>をレンダリングしてください。<Marker>のicon属性に、先ほど作成したcreatePriceIcon(camp.price, camp.is_hot)を適用してください。<Marker>をクリックすると、キャンプ場名を表示する<Popup>が表示されるようにしてください。完全なReactコンポーネントコードを提供してください。
🤖 AIが生成した完璧な地図レンダリング構造:
import React from 'react';
import { MapContainer, TileLayer, Marker, Popup } from 'react-leaflet';
import 'leaflet/dist/leaflet.css';
import { createPriceIcon } from './iconUtils'; // 先程のユーティリティ関数をインポート
export default function CampingMap({ camps }) {
// デフォルトの中心点(台湾の中心:埔里)
const center = [23.9738, 120.9820];
return (
// MapContainerには具体的な高さ設定が必須、そうでないと地図が表示されない
<div className="w-full h-[600px] rounded-2xl overflow-hidden shadow-2xl relative z-0">
<MapContainer
center={center}
zoom={7}
scrollWheelZoom={true}
className="w-full h-full"
>
{/* ベースマッププロバイダー(ここではCartoDBのダークマップを使用) */}
<TileLayer
attribution='© <a href="https://carto.com/">CartoDB</a>'
url="https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"
/>
{/* すべてのキャンプ場マーカーを配置 */}
{camps.map((camp) => (
<Marker
key={camp.id}
position={[camp.lat, camp.lng]}
icon={createPriceIcon(camp.price, camp.is_hot)} // 🔥 神レベルのカスタムアイコンを適用
>
{/* マーカークリック時に表示される美しいバブル */}
<Popup className="custom-popup">
<div className="p-2">
<h3 className="font-bold text-lg">{camp.name}</h3>
<p className="text-sm text-gray-500">{camp.address}</p>
<button className="mt-2 w-full bg-emerald-500 text-white rounded py-1 text-sm font-bold">
今すぐ予約
</button>
</div>
</Popup>
</Marker>
))}
</MapContainer>
</div>
);
}
💥 実戦3:データが5000件時の災害(Marker Clustering)
データが50件だけなら、上記のコードは完璧に動作します。 しかし5000件の場合、ウェブ���ージを開くとPCのファンが狂ったように回転し始め、画面が5秒間フリーズ、モバイルブラウザはクラッシュします。
なぜか?React-Leafletでは5000個の<Marker>は、Reactに5000個の実際のDOM要素を作成させ、5000個のクリックイベントリスナーをバインドすることを意味します。これはブラウザの限界を超えています。
この問題を解決するため、地図学では**Marker Clustering(マーカークラスタリング)**が発明されました。 台湾全体を表示するように地図を縮小(Zoom out)すると、システムは中部の1000のキャンプ場を融合し、「1000」と書かれた緑の大きな円にします。苗栗の山岳地帯にズームイン(Zoom in)すると、この1000の円は十数個の小さな円に分かれ、最終的に実際の価格タグに展開します。 これは視覚的な密集問題を解決するだけでなく、90%のパフォーマンスを救います!
💡 Vibe Prompt 実戦3:React-Leaflet-Clusterを導入
これは超上級者向けのパッケージで、公式ドキュメントだけでは実装に苦労します。AIにお任せしましょう!
[!IMPORTANT] 以下のPromptをAIにコピーして送信:
地図上に5000個のMarkerがあり��深刻なパフォーマンス問題が発生しています。react-leaflet-clusterパッケージを使用してMarker Clusteringを実装したいです。先程の<CampingMap>コードを修正してください。要件:1. <MarkerClusterGroup>ですべての<Marker>をラップ2. chunkedLoading={true}を設定してバッチ読み込みを有効化、メインスレッドの凍結を防止3. maxClusterRadius={50}を設定し、融合するまでの距離を近く設定修正後の完全なコードと、なぜこれがパフォーマンス問題を解決するのかの解説を提供してください。
🤖 AIの究極のパフォーマンス救世主:
import React from 'react';
import { MapContainer, TileLayer, Marker, Popup } from 'react-leaflet';
// 🔥 クラスタリングパッケージをインポート(npm install react-leaflet-clusterを実行)
import MarkerClusterGroup from 'react-leaflet-cluster';
import 'leaflet/dist/leaflet.css';
import { createPriceIcon } from './iconUtils';
export default function CampingMap({ camps }) {
const center = [23.9738, 120.9820];
return (
<div className="w-full h-[600px] rounded-2xl overflow-hidden shadow-2xl relative z-0">
<MapContainer center={center} zoom={7} className="w-full h-full">
<TileLayer url="https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png" />
{/*
🌟 パフォーマンス救世主登場!すべてのMarkerをMarkerClusterGroupでラップ。
なぜこれがパフォーマンスを救うのか?
背後で複雑な数学的計算が行われます:
現在のズームレベルで、画面上に1000個の近接ポイントがある場合、
1000個のDOMを描画「せず」、「1個」の数字付き円DOMだけを描画します。
地図を拡大すると、動的に計算して展開します。これでDOM数が5000から50未満に激減!
*/}
<MarkerClusterGroup
chunkedLoading={true} // クラスタ計算をバッチ処理、ブラウザのフリーズを防止
maxClusterRadius={50} // ピクセル距離:50px未満で融合
showCoverageOnHover={false} // マウスホバー時に醜い多角形範囲を表示しない
>
{camps.map((camp) => (
<Marker
key={camp.id}
position={[camp.lat, camp.lng]}
icon={createPriceIcon(camp.price, camp.is_hot)}
>
<Popup>
<div className="p-2 font-bold">{camp.name}</div>
</Popup>
</Marker>
))}
</MarkerClusterGroup>
</MapContainer>
</div>
);
}
🎨 実戦4:「クラスター円」の外観をカスタマイズ
上記のパッケージをインストールすると、価格カプセルは美しくなりますが、「数字」が表示されるクラスター円は、まだデフォルトの醜いスタイル(黄色や緑のグラデーション円)のままです。
完璧主義者の上級開発者として、この円もTailwindのグラスモーフィズムスタイルに改造しましょう!
💡 Vibe Prompt 実戦4:createClusterCustomIconで円を改造
Leafletの最深層APIを使用する必要があります:クラスター内のポイント数を取得し、再びL.divIconでHTMLを生成します。
[!IMPORTANT] 以下のPromptをAIにコピーして送信:
react-leaflet-clusterのクラスター円(Cluster Icon)の外観をカスタマイズしたいです。createCustomClusterIcon(cluster)関数を作成し、<MarkerClusterGroup iconCreateFunction={}>に渡してください。要件:1. clusterインスタンスから包含されているポイント数を取得(cluster.getChildCount())2. L.divIconを使用してHTML構造を返す3. Tailwindスタイル要件:半透明黒背景(bg-black/60)、グラスモーフィズム効果(backdrop-blur-md)、円形(rounded-full, w-12, h-12)、中央揃え白文字、蛍光緑のボーダー(border-2 border-emerald-400)完全なユーティリティ関数とコンポーネント更新を提供してください。
🤖 AIのハイエンドカスタムクラスター:
// コンポーネント外でこの強力なカスタム関数を定義
const createCustomClusterIcon = (cluster) => {
// この円の下に含まれるキャンプ場の総数を取得
const count = cluster.getChildCount();
// 未来的でモダンなTailwindグラスモーフィズム円を設計
const htmlContent = `
<div class="flex items-center justify-center w-12 h-12 rounded-full bg-black/60 backdrop-blur-md border-2 border-emerald-400 text-emerald-400 font-bold shadow-[0_0_15px_rgba(52,211,153,0.5)] transition-transform hover:scale-110">
${count}
</div>
`;
return L.divIcon({
html: htmlContent,
className: '', // 必須、デフォルト背景を削除
iconSize: L.point(48, 48, true), // 幅高さを48pxに指定(w-12 h-12に対応)
});
};
// ... コンポーネント内で使用 ...
<MarkerClusterGroup
chunkedLoading={true}
iconCreateFunction={createCustomClusterIcon} // 🔥 カスタム関数をバインド
>
{/* 内部のMarkerは変更なし */}
</MarkerClusterGroup>
🔍 詳細解説: このコードを適用すると、地図インターフェースは質的に飛躍します。 ダークベースマップ上に、蛍光緑ボーダ