📍 第十二章:Leaflet.js カスタム高度マーカーとクラスタリング最適化

前章のCrewAIクローラー実戦で、台湾全土のキャンプ場データ(名称、価格、緯度経度)を数千件収集し、Supabaseデータベースに保存することに成功しました。

しかし今、フロントエンド開発における重大な課題に直面しています。 もしLeafletのデフォルト構文で5000ものポイントを一度に地図上にプロットすると:

  1. 視覚的災害:5000個の同じ青い水滴マーカーが画面を埋め尽くし、地図のベースマップが見えなくなり、どのキャンプ場が安いか判別不能になります。
  2. パフォーマンス災害: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-200 4. 高度なロジック: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='&copy; <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>

🔍 詳細解説: このコードを適用すると、地図インターフェースは質的に飛躍します。 ダークベースマップ上に、蛍光緑ボーダ

完全なチュートリアルをロック解除

このチャプターは有料コンテンツです。プロジェクトに参加して、10以上の神レベルのPromptや実際のソースコード例を含む、5000字以上の深い分析をロック解除してください!