📍 第十二章:Leaflet.js 自訂高階地圖標記與群集優化

在上一章的 CrewAI 爬蟲實戰中,我們成功地從網路上採集了幾千筆全台露營地的資料,包含名稱、價格、以及最重要的經緯度,並存入了 Supabase 資料庫。

現在,我們面臨前端工程的巨大挑戰。 如果你直接用 Leaflet 的預設語法,把這 5000 個點一次畫到地圖上:

  1. 視覺災難:畫面上會擠滿 5000 個一模一樣的藍色小水滴,使用者根本看不到地圖底圖,也分不出哪個營地比較便宜。
  2. 效能災難:Leaflet 預設會為每一個標記建立一個 DOM 元素 (div 或 img)。5000 個 DOM 會瞬間吃光手機的記憶體,讓滑動地圖變得無比卡頓,甚至讓瀏覽器直接崩潰閃退。

如果你看過 Airbnb 的地圖,你會發現他們的標記不是圖示,而是直接在地圖上顯示「價格」。當你縮小地圖時,這些價格會融合成一個圓圈寫著「此區有 150 個房源」。 這堂高階實戰課,我們就要用 Vibe Coding 的方式,指揮 AI 為我們實作這套 Airbnb 等級的 自訂 HTML 標記 (Custom HTML Markers)標記叢集 (Marker Clustering) 系統!


🎨 實戰 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 最強大的功能之一,可以渲染任意 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> 裡面有一個 camps 陣列 (從 Supabase 抓下來的)。 請幫我用 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 筆,這時候打開網頁,你的電腦風扇會開始狂轉,畫面會卡住長達 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} // 分批計算叢集,確保計算時瀏覽器不會整個凍結 (Freezing)
          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>

🔍 深度解析: 當這段程式碼放上去後,你的地圖介面將產生質的飛躍。 深色的底圖上,漂浮著帶有螢光綠邊框的毛玻璃圓圈。當你點擊寫著「45」的圓圈時,地圖會自動放大,並如煙火般散開成 45 個絕美的 Airbnb 價格膠囊。 這就是「把免費套件做出百萬級專案價值」的最佳範例!


🚫 終極避坑指南:當 Leaflet 遇到 React 18 的 StrictMode

這是一個極度隱晦的深坑!在本地開發時 (npm run dev),因為 React 18 預設啟動了 <StrictMode>,它會把元件「渲染兩次」來檢查錯誤。 這會導致地圖被初始化兩次,結果整個地圖畫面會變成一片灰色,或是叢集圓圈重複出現。

💡 Vibe Prompt 實戰 5:解決 React 18 地圖灰屏地獄

當你遇到網頁正常,但地圖某個區塊灰掉、或是報錯 Map container is already initialized 時,把這個 Prompt 丟給 AI。

[!IMPORTANT] 請複製以下 Prompt 傳送給 AI:

我在 React 18 中使用 React-Leaflet,在開發模式 (npm run dev) 下地圖有時會出現灰屏,控制台出現 "Map container is already initialized" 的錯誤。 我知道這跟 React.StrictMode 有關。 請教我兩種解法: 1. 如何在 Vite 的設定或 main.jsx 中暫時關閉 StrictMode 來避開這個地圖套件的 Bug? 2. 如果不想關閉 StrictMode,有沒有什麼 useEffect 或 useRef 的解法可以避免 Leaflet 重複初始化? 請給出詳細說明。

🤖 AI 的排障專家指南:

這是 Leaflet 與 React 18 的著名歷史衝突,因為 Leaflet 的底層架構非常古老,它不喜歡同一個 DOM 被建立兩次。

【解法一:最快速暴力的解法 (關閉 StrictMode)】
這會立刻解決問題。開啟你的 `src/main.jsx` 或 `index.js`:
```jsx
// ❌ 舊的寫法:
ReactDOM.createRoot(document.getElementById('root')).render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
)

// ✅ 移除 StrictMode,改成這樣:
ReactDOM.createRoot(document.getElementById('root')).render(
  <App />
)

【解法二:不關閉 StrictMode 的優雅解法】 如果你在 Next.js 或一定要保留 StrictMode,可以透過給 <MapContainer> 強制設定一個每次都不同的 key,讓 React 放棄重用它:

const mapKey = useMemo(() => Date.now(), []);

return (
  <MapContainer key={mapKey} ...>
    ...
  </MapContainer>
)

---

## ✅ 本章總結與終極實戰心法

在這個長達 6000 字的進階實戰章節中,我們不僅解決了視覺問題,更解決了效能瓶頸。

回顧我們學到的高階技巧:
1. **L.divIcon 魔術**:拋棄了死板的圖片,用字串組合出 Tailwind CSS 寫的 `Airbnb 風格價格標籤`。
2. **解決 DOM 效能災難**:透過引入 `react-leaflet-cluster`,把 5000 個節點的運算壓縮到只剩下 50 個,讓手機滑動順暢無比。
3. **客製化叢集圓圈**:深入 API 底層,把毛玻璃、螢光陰影等賽博龐克風格套用在地圖叢集上。
4. **React StrictMode 避坑**:學會如何排解地圖套件在現代前端框架中的千年大坑。

**Vibe Coding 核心思維:**
你不需要去背誦 `cluster.getChildCount()` 這些底層 API。你只需要知道:「這個套件的圓圈很醜,我想要用 Tailwind 把它的背景改成透明模糊」。
當你把這個「視覺需求」用精準的語言描述給 AI 聽,並附上你想要的 Tailwind 關鍵字,AI 自然會幫你找到對應的底層 API 並寫出完美的程式碼。

到目前為止,我們的地圖已經美到無法挑剔了。
但在真實的網頁中,地圖不會是孤立的。通常左邊會有一個「側邊欄 (Sidebar)」,顯示營地的詳細列表。
當你點擊左邊清單的某個營地時,右邊的地圖要自動飛過去;當你拖動地圖時,左邊的清單要自動過濾出畫面內的營地。
這牽扯到複雜的「元件間狀態共享 (State Lifting)」。

準備好了嗎?我們將在下一章:**第十三章:地圖與側邊欄的靈魂共振 (State Interaction)** 中,教你如何用 AI 打通這套複雜的神經網路!

解鎖完整教學內容

本章為付費內容。加入專案即可解鎖超過 5000 字的深度解析,包含 10 個以上神級 Prompt 與真實 Source Code 範例!