🧠 第十三章:地図とサイドバーの魂の共鳴 (双方向連動)
前章では、クラスタリング技術とAirbnb風の価格タグで美しいキャンプ地図を作成しました。 しかし、Google MapsやAgoda、本物のAirbnbを使うと、インターフェースが単なる地図だけではないことに気づくでしょう。 通常、画面の左側(またはモバイル版では下部)に**「サイドバーリスト (Sidebar List)」**が表示され、近くのキャンプ場の写真、詳細な価格、評価が表示されます。
ここで、真の技術的挑戦が始まります:
- リストクリックで地図連動:左側で「星空キャンプ場」をクリックすると、右側の地図が苗栗の山岳地帯にスムーズに移動し、星空キャンプ場のラベルが拡大表示される。
- 地図ドラッグでリスト連動:地図を宜蘭にドラッグすると、左側のリストが「自動フィルタリング」され、現在の地図範囲内(宜蘭)のキャンプ場のみを表示し、苗栗のキャンプ場は表示されなくなる!
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)。activeCampId と setActiveCampId を props として <Sidebar> と <CampingMap> に渡す方法を含む、<ExplorePage> のコード構造を記述してください。ここでは骨組みのみを記述してください。
🤖 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が変更された時に、地図がそのキャンプ場の座標にスムーズに飛ぶようにしたいです。useMap() hookを使用してLeaflet地図実体を取得する、<MapController>という内部コンポーネントを作成してください。要件: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]); // この3つのいずれかが変更されると飛行がトリガーされる
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) を取得し、左側のリストをフィルタリングしたいです。useMapEventsを使用して 'moveend' (移動終了) と 'zoomend' イベントを監視する、<BoundsObserver> という不可視コンポーネントを作成してください。要件:1. useMapEventsで 'moveend' と 'zoomend' イベントを監視。2. イベント発生時に map.getBounds() を呼び出して現在の境界を取得。3. props.onBoundsChange を通じて境界データを外部に伝達。このコンポーネントの完全なコードと、L.LatLngBounds.contains() を使用してキャンプ場が画面内にあるかどうかを判断する方法の解説を記述してください。
🤖 AIの高度なレーダー検出器:
import { useMapEvents } from 'react-leaflet';
// 不可視のレーダー検出器
function BoundsObserver({ onBoundsChange }) {
// Leaflet専用のイベントリスナーフックを使用
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を持っています。Tailwind CSSを使用して動的に判断する<div>のclassNameを記述してください: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> がある場合を想像してください。
activeCampId と setActiveCampId をリレーのように5層も下に渡す必要があります。
これは "Prop Drilling (プロパティ掘削地獄)" と呼ばれ、コードが醜くなるだけでなく、エラーが発生しやすくなります。
ここで、真のプロはReactの究極兵器である Context API を使用します。
これはプロジェクトの上空に通信衛星を打ち上げるようなもので、どのコンポーネントもこの衛星に接続すれば、直接 activeCampId を取得でき、中間層を経由する必要がなくなります!
💡 Vibe Prompt 実戦 5:AIに通信衛星 (Context) を構築させる
[!IMPORTANT] 以下のPromptをAIにコピーして送信してください: