🧠 第十三章:地圖與側邊欄的靈魂共振 (雙向互動)

在上一章,我們用叢集技術與 Airbnb 風格的價格標籤,打造了一張絕美的露營地圖。 但如果你去用 Google Maps、Agoda 或是真正的 Airbnb,你會發現它們的介面絕對不會只有一張地圖。 通常畫面的左半邊(或手機版的下半部)會有一個**「側邊欄清單 (Sidebar List)」**,顯示附近營地的照片、詳細價格與評價。

這時候,真正的技術挑戰來了:

  1. 點擊清單聯動地圖:當你在左邊點擊了「星空營地」,右邊的地圖必須滑順地飛到苗栗山區,並把星空營地的標籤放大。
  2. 拖動地圖聯動清單:當你把地圖拖曳到宜蘭時,左邊的清單必須「自動過濾」,只顯示目前地圖範圍內(宜蘭)的營地,不能再顯示苗栗的營地!

在 React 的世界中,側邊欄 (<Sidebar>) 和地圖 (<CampingMap>) 通常是兩個完全獨立的元件。它們就像是兩個住在不同房間的員工,互相聽不到對方講話。 這堂 6000 字的高階架構課,我們將教你如何用 Vibe Coding,指使 AI 為這兩個元件牽起一條通訊神經,實作這套價值幾十萬的「雙向連動系統」!


🏗️ 實戰 1:狀態提升 (State Lifting) 的核心概念

最直接的解法,就是找一個「主管」,把這兩個元件包起來。這個主管通常就是最外層的 <App><MapPage> 元件。 由主管來保管「目前選中的營地 (selectedCamp)」這個狀態,然後同時把這個狀態往下發給地圖和側邊欄。

💡 Vibe Prompt 實戰 1:建立主管層級的架構

與其自己去寫複雜的 prop drilling (屬性傳遞),我們直接跟 AI 描述整個系統的「資料流動方向」。

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

我正在用 React 開發一個雙欄佈局的頁面 <ExplorePage>。 左邊是 <Sidebar> (顯示營地清單),右邊是 <CampingMap> (顯示 Leaflet 地圖)。 請幫我用「狀態提升 (State Lifting)」的模式,在 <ExplorePage> 中建立以下狀態: 1. allCamps: 從資料庫抓回來的所有營地資料。 2. activeCampId: 目前使用者選中的營地 ID (預設為 null)。 請寫出 <ExplorePage> 的程式碼結構,包含如何把 activeCampId 和 setActiveCampId 當作 props 傳遞給 <Sidebar> 和 <CampingMap>。 這裡只需寫出骨架結構即可。

🤖 AI 的高階架構規劃:

import React, { useState, useEffect } from 'react';
import Sidebar from './Sidebar';
import CampingMap from './CampingMap';
import { fetchCamps } from './api';

export default function ExplorePage() {
  // 主管層級保管的狀態
  const [allCamps, setAllCamps] = useState([]);
  
  // 🔥 聯動的核心靈魂:目前被選中的營地 ID
  const [activeCampId, setActiveCampId] = useState(null);

  useEffect(() => {
    // 假設這裡去 Supabase 抓了資料
    fetchCamps().then(data => setAllCamps(data));
  }, []);

  return (
    <div className="flex h-screen overflow-hidden">
      {/* 左邊側邊欄:佔據 30% 寬度 */}
      <div className="w-1/3 h-full bg-white shadow-2xl z-10 overflow-y-auto">
        <Sidebar 
          camps={allCamps} 
          activeCampId={activeCampId} 
          // 把修改狀態的遙控器交給 Sidebar
          onCampSelect={setActiveCampId} 
        />
      </div>

      {/* 右邊地圖:佔據 70% 寬度 */}
      <div className="w-2/3 h-full relative z-0">
        <CampingMap 
          camps={allCamps} 
          activeCampId={activeCampId} 
          // 地圖也可以修改狀態!(例如點擊了地圖上的標籤)
          onCampSelect={setActiveCampId} 
        />
      </div>
    </div>
  );
}

🔍 深度解析: 這就是 React 的核心哲學:「Single Source of Truth (單一資料來源)」。 地圖和清單自己都不知道現在選了誰,它們只聽主管 (ExplorePage) 的。當你在 Sidebar 點了一個項目,Sidebar 會呼叫 onCampSelect,通知主管把 activeCampId 變成 5。主管一更新,React 就會瞬間把這個 5 同時發送給 Sidebar 和地圖,兩個元件就會同時做出反應!


✈️ 實戰 2:從清單飛向地圖 (FlyTo)

現在我們的地圖拿到了 activeCampId。我們希望當這個 ID 改變時,地圖要自動像無人機一樣飛到那個營地的上空。 在 React-Leaflet 中,地圖的實體 (Map instance) 是隱藏在底層的。我們必須寫一個自訂的 Hook 元件來控制地圖。

💡 Vibe Prompt 實戰 2:指揮 AI 寫出 FlyTo 元件

這是一個極度困難的 Leaflet 考題。我們不能重新渲染整個地圖,我們只要呼叫地圖的 flyTo 方法。

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

在 React-Leaflet 中,我需要在 props.activeCampId 改變時,讓地圖平滑地飛到該營地的座標。 請幫我寫一個名為 <MapController> 的內部元件,放在 <MapContainer> 裡面。 需求: 1. 使用 useMap() hook 取得 Leaflet 地圖實體。 2. 接收 activeCampId 和 camps 陣列作為 props。 3. 使用 useEffect 監聽 activeCampId 的變化。 4. 當 ID 改變且找到對應的營地時,呼叫 map.flyTo([lat, lng], 14, { duration: 1.5 })。 請給我這個 MapController 元件的程式碼,並示範如何把它放入 <MapContainer> 中。

🤖 AI 的高階飛行控制塔:

import { useMap } from 'react-leaflet';
import { useEffect } from 'react';

// 塔台控制器元件 (它不渲染任何畫面,純粹用來在背後操縱地圖)
function MapController({ activeCampId, camps }) {
  // 🔥 神奇的 hook:取得目前的地圖實體!
  const map = useMap();

  useEffect(() => {
    // 如果沒有選中任何東西,就什麼都不做
    if (!activeCampId || !camps) return;

    // 找出目前被選中的那筆營地資料
    const targetCamp = camps.find(c => c.id === activeCampId);
    
    if (targetCamp) {
      // 啟動無人機飛行模式!
      // 參數:[緯度, 經度], 縮放等級(14代表滿近的), 動畫設定
      map.flyTo(
        [targetCamp.lat, targetCamp.lng], 
        14, 
        {
          duration: 1.5, // 飛行花費 1.5 秒
          easeLinearity: 0.25 // 飛行軌跡的平滑度
        }
      );
    }
  }, [activeCampId, camps, map]); // 當這三個東西任何一個改變時,觸發飛行

  return null; // 控制器不需要長出畫面
}

/*
【AI 示範如何使用】:
<MapContainer center={[23.5, 121]} zoom={7}>
   <TileLayer ... />
   
   {/* 把控制塔放在這裡!它會默默在背後工作 */}
   <MapController activeCampId={activeCampId} camps={camps} />
   
   {/* ...下面是 Marker 等等... */}
</MapContainer>
*/

🔍 實戰 3:從地圖逆向過濾清單 (Bounds Detection)

「飛過去」只解決了一半的問題。更難的需求是:「當我用滑鼠拖曳地圖,拖到台南的時候,左邊的 Sidebar 必須自動只顯示台南(目前螢幕畫面內)的營地。」

這意味著地圖元件必須「偵測目前的經緯度邊界 (Bounds)」,並把這個邊界傳回給主管,讓主管去過濾 allCamps

💡 Vibe Prompt 實戰 3:實作地圖邊界偵測器

我們再請 AI 寫一個隱形的間諜元件,專門負責通報地圖目前看得到哪裡。

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

我需要在拖曳/縮放 Leaflet 地圖時,取得目前地圖的可視邊界 (Bounds),藉此過濾左側的清單。 請幫我寫一個名為 <BoundsObserver> 的隱藏元件,放進 <MapContainer> 裡。 需求: 1. 使用 useMapEvents 監聽 'moveend' (移動結束) 與 'zoomend' 事件。 2. 當事件觸發時,呼叫 map.getBounds() 取得目前邊界。 3. 透過 props.onBoundsChange 將邊界資料傳出去。 請給我這個元件的完整程式碼,並在註解中解釋如何使用 L.LatLngBounds.contains() 來判斷營地是否在畫面內。

🤖 AI 的高階雷達偵測器:

import { useMapEvents } from 'react-leaflet';

// 隱形的雷達偵測器
function BoundsObserver({ onBoundsChange }) {
  // 使用 Leaflet 專屬的事件監聽 hook
  const map = useMapEvents({
    // 當地圖「移動結束」時觸發
    moveend: () => {
      onBoundsChange(map.getBounds());
    },
    // 當地圖「縮放結束」時觸發
    zoomend: () => {
      onBoundsChange(map.getBounds());
    }
  });

  return null;
}

/*
【AI 深度解析過濾邏輯】:
當主管 <ExplorePage> 收到雷達傳回來的 bounds (邊界物件) 後,
你可以用陣列的 .filter() 配合 bounds.contains() 來過濾營地:

const visibleCamps = allCamps.filter(camp => {
  // 把營地的經緯度轉成 Leaflet 格式,然後問雷達:「這個點有在你的掃描範圍內嗎?」
  return bounds.contains([camp.lat, camp.lng]);
});

然後把這個 visibleCamps 傳給左邊的 Sidebar,清單就會神奇地跟著地圖連動了!
*/

🎨 實戰 4:極致的選中狀態視覺回饋

當一個營地被選中時,除了地圖要飛過去之外,我們還希望在視覺上讓它「脫穎而出」。 例如:地圖上該營地的價格標籤要變成黃色並發光;左側 Sidebar 的該筆清單要加上深色背景與邊框。

💡 Vibe Prompt 實戰 4:動態 Tailwind 樣式設計

這不需要寫新元件,只需要極致的 Tailwind 邏輯判斷。

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

在 Sidebar 的營地清單中,我使用 .map 迴圈渲染每一個營地。 我現在有 activeCampId。請幫我寫一個 <div> 的 className,使用 Tailwind CSS 動態判斷: 如果 camp.id === activeCampId: 背景必須是 bg-emerald-500/20,邊框是 border-emerald-500,且左邊要有 4px 的粗綠色飾條。 如果沒有被選中: 背景是 bg-white,邊框是 border-gray-200,hover 時會變成 bg-gray-50。 請用 React 樣板字面值 (Template literals) 給出乾淨的寫法。

🤖 AI 的百萬級視覺回饋程式碼:

{visibleCamps.map(camp => {
  // 判斷這張卡片是不是「天選之卡」
  const isActive = camp.id === activeCampId;

  return (
    <div 
      key={camp.id}
      onClick={() => onCampSelect(camp.id)} // 點擊時通知主管換人
      className={`
        relative p-4 mb-3 rounded-lg border-2 cursor-pointer transition-all duration-300
        ${isActive 
          ? 'bg-emerald-500/10 border-emerald-500 shadow-md shadow-emerald-500/20 scale-[1.02]' 
          : 'bg-white border-gray-100 hover:border-gray-300 hover:bg-gray-50'
        }
      `}
    >
      {/* 如果被選中,在最左邊畫一條霸氣的綠色螢光線 */}
      {isActive && (
        <div className="absolute left-0 top-0 bottom-0 w-1.5 bg-emerald-500 rounded-l-lg"></div>
      )}
      
      <h3 className={`font-bold ${isActive ? 'text-emerald-700' : 'text-gray-900'}`}>
        {camp.name}
      </h3>
      <p className="text-gray-500 text-sm mt-1">NT$ {camp.price} / 晚</p>
    </div>
  )
})}

🔍 深度解析: 這種視覺設計被稱為 "Micro-interactions (微互動)"。當你加上 scale-[1.02] (稍微放大) 和霸氣的左側綠線,整個應用程式看起來就會像是由頂尖矽谷設計師操刀的產品,而不是大學生的期末專題。


🤯 高階升級:打破框架的 Context API

上面的「狀態提升 (State Lifting)」非常棒,但如果你的專案變得超級龐大呢? 假設你的 Sidebar 裡面還包了 <SearchBox>,裡面又包了 <FilterPanel>,裡面又包了 <PriceSlider>。 你要把 activeCampIdsetActiveCampId 像接力賽一樣,一層一層地傳遞 5 層下去。 這叫做 "Prop Drilling (屬性鑽探地獄)",不僅程式碼醜到爆,而且超容易出錯。

這時候,真正的高手會使用 React 的終極武器:Context API。 它就像是在你的專案上空打一顆通訊衛星,任何角落的元件,只要連上這顆衛星,就能直接拿到 activeCampId,不需要經過中間的層層傳遞!

💡 Vibe Prompt 實戰 5:指揮 AI 打造通訊衛星 (Context)

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

為了避免 Props Drilling,我想要重構我的地圖狀態管理,改用 React Context API。 請幫我寫一個 MapContext.jsx 檔案。 需求: 1. 建立並 export MapContext。 2. 寫一個 MapProvider 元件,裡面包含 state: activeCampId, setActiveCampId, visibleBounds, setVisibleBounds。 3. 寫一個自訂 Hook (useMapState),讓其他元件可以方便地取得這些狀態。如果不在 Provider 內呼叫,要噴出明確的錯誤。 請給我極度專業、可直接用於商業產品的完整程式碼。

🤖 AI 的企業級通訊架構:

import React, { createContext, useState, useContext } from 'react';

// 1. 建立通訊衛星
const MapContext = createContext();

// 2. 建立發射站 (Provider):負責保管所有重要狀態,並把狀態打包廣播出去
export function MapProvider({ children }) {
  const [activeCampId, setActiveCampId] = useState(null);
  const [visibleBounds, setVisibleBounds] = useState(null);

  // 把所有要分享的東西打包成一個大禮包
  const value = {
    activeCampId,
    setActiveCampId,
    visibleBounds,
    setVisibleBounds
  };

  return (
    <MapContext.Provider value={value}>
      {children}
    </MapContext.Provider>
  );
}

// 3. 建立接收器 Hook:其他元件只要呼叫 useMapState() 就能直接拿到狀態
export function useMapState() {
  const context = useContext(MapContext);
  
  // 🔥 防呆機制:如果工程師忘記把元件包在 Provider 裡面,就給他一個超大聲的警告!
  if (context === undefined) {
    throw new Error('useMapState 必須被包裝在 MapProvider 內部才能使用!');
  }
  
  return context;
}

🚀 實戰 6:全面重構,接入衛星訊號

有了這顆衛星後,你的 <ExplorePage> 變得無比乾淨!

💡 Vibe Prompt 實戰 6:使用 Provider 重構頁面

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

請示範如何把剛剛寫好的 <MapProvider> 應用在最外層的 <App> 或 <ExplorePage> 中。 並且示範,在深層的 <SidebarItem> 元件中,如何使用 useMapState() 直接拿到 activeCampId 和修改它的函數,而不需要依賴 props。

🤖 AI 示範終極優雅的寫法:

// 在最外層,用衛星發射站把大家包起來
function App() {
  return (
    <MapProvider>
      <div className="flex h-screen">
        {/* 你看!現在完全不需要傳遞任何 Props 了!清爽到爆! */}
        <Sidebar />
        <CampingMap />
      </div>
    </MapProvider>
  );
}

// 在 100 層深的 Sidebar 某個角落元件...
import { useMapState } from './MapContext';

function SidebarItem({ camp }) {
  // 📡 裝上接收器,直接從太空中抓下狀態!
  const { activeCampId, setActiveCampId } = useMapState();
  const isActive = camp.id === activeCampId;

  return (
    <div onClick={() => setActiveCampId(camp.id)}>
      {camp.name} {isActive ? "(已選中)" : ""}
    </div>
  );
}

✅ 本章總結與終極架構心法

這個長達 6000 字的超深章節,是全端工程師在前端領域的分水嶺。 許多人會刻死板的地圖,但只有真正的高手,才知道如何讓數十個元件像交響樂一樣完美連動。

回顧這堂價值連城的架構課:

  1. 單一資料來源 (Single Source of Truth):元件不要各說各話,找一個主管(或 Provider)來統籌。
  2. 無形的控制器:利用 <MapController> 這種不渲染畫面的隱藏元件,在背後優雅地操控 Leaflet API。
  3. 雷達偵測器:利用 map.getBounds() 逆向將地圖的可視區域回傳,實現「只顯示地圖內營地」的高級體驗。
  4. 終極武器 Context API:當專案大到 props 傳不下去時,直接發射一顆狀態衛星,讓所有元件直接連線取值。

這就是 露營車泊地圖資訊平台 (Camping Map Platform) 的最後一塊拼圖。 從第一天的 Astro 基礎、到 Supabase 資料庫規劃、到 CrewAI 讓幾千個機器人幫你搜集全台資料、再到最後做出跟 Airbnb 一樣神級的雙向連動地圖。

你現在做出來的系統,已經完全具備了商業變現的價值。你可以把它包裝成一個訂閱制的找營地平台,或是賣給露營車出租公司當作他們的官方找點系統。 請深呼吸,為自己感到驕傲。因為在 Vibe Coding 的加持下,你一個人完成了一整個工程團隊要花半年才能做完的事!

準備好挑戰另一種截然不同的商業模式了嗎?我們下一個專案:Line 打卡與薪資系統 見!

解鎖完整教學內容

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