610 lines
14 KiB
Markdown
610 lines
14 KiB
Markdown
# GoogleMap Component - Final Architecture
|
||
|
||
## 📐 COMPONENT MİMARİSİ
|
||
|
||
### State Management
|
||
|
||
```typescript
|
||
// Map instance refs
|
||
const mapRef = useRef<HTMLDivElement>(null);
|
||
const mapInstanceRef = useRef<google.maps.Map | null>(null);
|
||
|
||
// Marker management
|
||
const markersRef = useRef<Map<string, google.maps.Marker>>(new Map());
|
||
const polylineRef = useRef<google.maps.Polyline | null>(null);
|
||
const infoWindowRef = useRef<google.maps.InfoWindow | null>(null);
|
||
|
||
// Center control
|
||
const hasCenteredRef = useRef(false); // ✅ Center sadece 1 kez
|
||
|
||
// Loading state
|
||
const [isScriptLoaded, setIsScriptLoaded] = useState(false);
|
||
const [loadError, setLoadError] = useState<string | null>(null);
|
||
```
|
||
|
||
---
|
||
|
||
## 🔄 EFFECT LIFECYCLE
|
||
|
||
### Effect 1: Google Maps Script Loading
|
||
|
||
**Dependency:** `[]` (mount only)
|
||
|
||
**Sorumluluklar:**
|
||
- Google Maps script'ini yükle
|
||
- `isScriptLoaded` state'ini güncelle
|
||
- Error handling
|
||
|
||
```typescript
|
||
useEffect(() => {
|
||
loadGoogleMapsScript()
|
||
.then(() => setIsScriptLoaded(true))
|
||
.catch((error) => {
|
||
console.error('Google Maps yükleme hatası:', error);
|
||
setLoadError('Harita yüklenemedi. Lütfen sayfayı yenileyin.');
|
||
});
|
||
}, []);
|
||
```
|
||
|
||
---
|
||
|
||
### Effect 2: Map Initialization
|
||
|
||
**Dependency:** `[isScriptLoaded, places]`
|
||
|
||
**Sorumluluklar:**
|
||
- Map instance'ı oluştur (sadece 1 kez)
|
||
- Initial center hesapla (places varsa ilk place, yoksa default)
|
||
- InfoWindow oluştur
|
||
- hasCenteredRef'i set et
|
||
|
||
```typescript
|
||
useEffect(() => {
|
||
if (!mapRef.current || !isScriptLoaded || !window.google) return;
|
||
if (mapInstanceRef.current) return; // ✅ Zaten oluşturulmuş
|
||
|
||
try {
|
||
const initialCenter = places.length > 0
|
||
? { lat: places[0].lat, lng: places[0].lng }
|
||
: { lat: 38.9637, lng: 35.2433 };
|
||
|
||
const mapInstance = new google.maps.Map(mapRef.current, {
|
||
center: initialCenter,
|
||
zoom: 12,
|
||
// ... map options
|
||
});
|
||
|
||
mapInstanceRef.current = mapInstance;
|
||
infoWindowRef.current = new google.maps.InfoWindow();
|
||
hasCenteredRef.current = true;
|
||
} catch (error) {
|
||
console.error('Harita başlatma hatası:', error);
|
||
setLoadError('Harita oluşturulamadı.');
|
||
}
|
||
|
||
return () => {
|
||
// Cleanup: Sadece unmount'ta
|
||
markersRef.current.forEach(marker => marker.setMap(null));
|
||
markersRef.current.clear();
|
||
if (polylineRef.current) {
|
||
polylineRef.current.setMap(null);
|
||
}
|
||
};
|
||
}, [isScriptLoaded, places]);
|
||
```
|
||
|
||
---
|
||
|
||
### Effect 3: Marker Creation/Deletion
|
||
|
||
**Dependency:** `[places]` ⚠️ SADECE places
|
||
|
||
**Sorumluluklar:**
|
||
- Artık olmayan marker'ları sil
|
||
- Yeni marker'ları oluştur (zaten varsa ATLA)
|
||
- Event listener'ları ekle (click, hover)
|
||
- Auto-fit bounds (sadece ilk kez - hasCenteredRef)
|
||
|
||
**ÖNEMLİ:**
|
||
- ❌ onMarkerClick/onMarkerHover dependency DEĞİL
|
||
- ❌ hoveredPlaceId/selectedPlaceId/activeDayId dependency DEĞİL
|
||
- ✅ Marker recreation SADECE places değiştiğinde
|
||
|
||
```typescript
|
||
useEffect(() => {
|
||
if (!mapInstanceRef.current || !window.google) return;
|
||
|
||
const map = mapInstanceRef.current;
|
||
const currentPlaceIds = new Set(places.map(p => p.id));
|
||
|
||
// 1. Artık olmayan marker'ları sil
|
||
markersRef.current.forEach((marker, id) => {
|
||
if (!currentPlaceIds.has(id)) {
|
||
marker.setMap(null);
|
||
markersRef.current.delete(id);
|
||
}
|
||
});
|
||
|
||
// 2. Yeni marker'ları oluştur (zaten varsa ATLA)
|
||
places.forEach((place) => {
|
||
if (markersRef.current.has(place.id)) return; // ⚠️ ATLA
|
||
|
||
const label = `${(place.orderIndex || 0) + 1}`;
|
||
|
||
const marker = new google.maps.Marker({
|
||
position: { lat: place.lat, lng: place.lng },
|
||
map: map,
|
||
title: place.title,
|
||
label: { text: label, color: 'white', fontSize: '14px', fontWeight: 'bold' },
|
||
icon: createMarkerIcon(place.dayIndex || 0, 'default'),
|
||
zIndex: place.orderIndex || 0,
|
||
});
|
||
|
||
// Event listeners (closure içinde callback'leri yakala)
|
||
marker.addListener('click', () => {
|
||
if (onMarkerClick) onMarkerClick(place.id);
|
||
// ... info window, pan
|
||
});
|
||
|
||
marker.addListener('mouseover', () => {
|
||
if (onMarkerHover) onMarkerHover(place.id);
|
||
});
|
||
|
||
marker.addListener('mouseout', () => {
|
||
if (onMarkerHover) onMarkerHover(null);
|
||
});
|
||
|
||
markersRef.current.set(place.id, marker);
|
||
});
|
||
|
||
// 3. Auto-fit bounds (sadece ilk kez)
|
||
if (places.length > 0 && !hasCenteredRef.current) {
|
||
const bounds = new google.maps.LatLngBounds();
|
||
places.forEach(place => bounds.extend({ lat: place.lat, lng: place.lng }));
|
||
map.fitBounds(bounds);
|
||
|
||
const listener = google.maps.event.addListenerOnce(map, 'idle', () => {
|
||
const currentZoom = map.getZoom();
|
||
if (currentZoom && currentZoom > 15) {
|
||
map.setZoom(15);
|
||
}
|
||
});
|
||
|
||
hasCenteredRef.current = true;
|
||
}
|
||
|
||
return () => {
|
||
// Cleanup: Sadece unmount'ta
|
||
markersRef.current.forEach(marker => marker.setMap(null));
|
||
markersRef.current.clear();
|
||
};
|
||
}, [places]); // ⚠️ SADECE places
|
||
```
|
||
|
||
---
|
||
|
||
### Effect 4: Visual Updates (Icon, Visibility, Animation)
|
||
|
||
**Dependency:** `[hoveredPlaceId, selectedPlaceId, activeDayId, places]`
|
||
|
||
**Sorumluluklar:**
|
||
- Marker visibility güncelle (activeDayId)
|
||
- Marker icon güncelle (hover/select state)
|
||
- Marker zIndex güncelle
|
||
- Marker animation güncelle
|
||
- Marker label güncelle
|
||
|
||
**ÖNEMLİ:**
|
||
- ❌ Bu effect marker OLUŞTURMAZ
|
||
- ✅ Sadece mevcut marker'ları GÜNCELLER
|
||
- ✅ setIcon, setVisible, setZIndex, setAnimation, setLabel
|
||
|
||
```typescript
|
||
useEffect(() => {
|
||
if (!mapInstanceRef.current || !window.google) return;
|
||
|
||
markersRef.current.forEach((marker, id) => {
|
||
const place = places.find(p => p.id === id);
|
||
if (!place) return;
|
||
|
||
// 1. Visibility kontrolü
|
||
const isVisible = !activeDayId || place.dayId === activeDayId;
|
||
marker.setVisible(isVisible);
|
||
|
||
// 2. State belirleme
|
||
let state: 'default' | 'hover' | 'selected' = 'default';
|
||
|
||
if (id === selectedPlaceId) {
|
||
state = 'selected';
|
||
marker.setZIndex(1000);
|
||
marker.setAnimation(google.maps.Animation.BOUNCE);
|
||
setTimeout(() => marker.setAnimation(null), 2000);
|
||
} else if (id === hoveredPlaceId) {
|
||
state = 'hover';
|
||
marker.setZIndex(999);
|
||
marker.setAnimation(null);
|
||
} else {
|
||
marker.setZIndex(place.orderIndex || 0);
|
||
marker.setAnimation(null);
|
||
}
|
||
|
||
// 3. Icon güncelleme (sadece style - size/anchor sabit)
|
||
marker.setIcon(createMarkerIcon(place.dayIndex || 0, state));
|
||
|
||
// 4. Label güncelleme
|
||
const label = `${(place.orderIndex || 0) + 1}`;
|
||
marker.setLabel({
|
||
text: label,
|
||
color: 'white',
|
||
fontSize: state === 'default' ? '14px' : '16px',
|
||
fontWeight: 'bold'
|
||
});
|
||
});
|
||
}, [hoveredPlaceId, selectedPlaceId, activeDayId, places]);
|
||
```
|
||
|
||
---
|
||
|
||
### Effect 5: Polyline Update
|
||
|
||
**Dependency:** `[places, activeDayId, showPolyline]`
|
||
|
||
**Sorumluluklar:**
|
||
- Polyline oluştur/güncelle
|
||
- activeDayId'ye göre filtrele
|
||
- Gün renklerini uygula
|
||
|
||
```typescript
|
||
useEffect(() => {
|
||
if (!mapInstanceRef.current || !showPolyline) return;
|
||
|
||
const map = mapInstanceRef.current;
|
||
|
||
// Eski polyline'ı sil
|
||
if (polylineRef.current) {
|
||
polylineRef.current.setMap(null);
|
||
}
|
||
|
||
// activeDayId varsa sadece o günün place'lerini al
|
||
let filteredPlaces = places;
|
||
if (activeDayId) {
|
||
filteredPlaces = places.filter(p => p.dayId === activeDayId);
|
||
}
|
||
|
||
if (filteredPlaces.length < 2) return;
|
||
|
||
// Polyline path oluştur
|
||
const path = filteredPlaces.map(p => ({ lat: p.lat, lng: p.lng }));
|
||
|
||
// Polyline rengi (ilk place'in günü)
|
||
const firstPlace = filteredPlaces[0];
|
||
const dayColor = getDayColor(firstPlace.dayIndex || 0);
|
||
|
||
const polyline = new google.maps.Polyline({
|
||
path: path,
|
||
geodesic: true,
|
||
strokeColor: dayColor.stroke,
|
||
strokeOpacity: 0.8,
|
||
strokeWeight: 3,
|
||
map: map,
|
||
});
|
||
|
||
polylineRef.current = polyline;
|
||
}, [places, activeDayId, showPolyline]);
|
||
```
|
||
|
||
---
|
||
|
||
## 🎨 HELPER FUNCTIONS
|
||
|
||
### getDayColor
|
||
|
||
**Sorumluluk:** Gün index'ine göre renk döndür
|
||
|
||
```typescript
|
||
const getDayColor = (dayIndex: number): { fill: string; stroke: string } => {
|
||
const colors = [
|
||
{ fill: '#f97316', stroke: '#ea580c' }, // Turuncu (Gün 1)
|
||
{ fill: '#3b82f6', stroke: '#2563eb' }, // Mavi (Gün 2)
|
||
{ fill: '#10b981', stroke: '#059669' }, // Yeşil (Gün 3)
|
||
{ fill: '#8b5cf6', stroke: '#7c3aed' }, // Mor (Gün 4)
|
||
{ fill: '#ec4899', stroke: '#db2777' }, // Pembe (Gün 5)
|
||
{ fill: '#f59e0b', stroke: '#d97706' }, // Sarı (Gün 6)
|
||
{ fill: '#06b6d4', stroke: '#0891b2' }, // Cyan (Gün 7)
|
||
];
|
||
return colors[dayIndex % colors.length];
|
||
};
|
||
```
|
||
|
||
---
|
||
|
||
### createMarkerIcon
|
||
|
||
**Sorumluluk:** Marker icon oluştur (size/anchor SABİT)
|
||
|
||
```typescript
|
||
const createMarkerIcon = (
|
||
dayIndex: number,
|
||
state: 'default' | 'hover' | 'selected'
|
||
) => {
|
||
const scale = 20; // ⚠️ SABİT
|
||
const color = getDayColor(dayIndex);
|
||
const fillColor = state === 'default' ? color.fill : color.stroke;
|
||
|
||
return {
|
||
path: google.maps.SymbolPath.CIRCLE,
|
||
scale: scale, // ⚠️ SABİT
|
||
fillColor: fillColor,
|
||
fillOpacity: 1,
|
||
strokeColor: 'white',
|
||
strokeWeight: state === 'selected' ? 4 : 3,
|
||
anchor: new google.maps.Point(0, 0), // ⚠️ SABİT anchor
|
||
labelOrigin: new google.maps.Point(0, 0),
|
||
};
|
||
};
|
||
```
|
||
|
||
---
|
||
|
||
## 📊 DATA FLOW
|
||
|
||
### TripPlanner → GoogleMap
|
||
|
||
```typescript
|
||
// TripPlanner.tsx
|
||
const allPlaces = trip?.days?.flatMap((day: any, dayIndex: number) => {
|
||
return day.places?.map((place: any, orderIndex: number) => ({
|
||
id: place.id,
|
||
lat: place.position.lat,
|
||
lng: place.position.lng,
|
||
dayId: day.id,
|
||
dayIndex: dayIndex,
|
||
orderIndex: orderIndex,
|
||
title: place.name,
|
||
// ✅ Color YOK - GoogleMap içinde hesaplanacak
|
||
})) || [];
|
||
}) || [];
|
||
|
||
<GoogleMap
|
||
places={allPlaces}
|
||
hoveredPlaceId={hoveredPlaceId}
|
||
selectedPlaceId={selectedPlaceId}
|
||
activeDayId={activeDayId}
|
||
onMarkerClick={handleMarkerClick}
|
||
onMarkerHover={handleMarkerHover}
|
||
showPolyline={true}
|
||
/>
|
||
```
|
||
|
||
---
|
||
|
||
### GoogleMap Props
|
||
|
||
```typescript
|
||
interface PlaceData {
|
||
id: string;
|
||
lat: number;
|
||
lng: number;
|
||
dayId?: string;
|
||
dayIndex?: number;
|
||
orderIndex?: number;
|
||
title: string;
|
||
// ❌ color YOK
|
||
}
|
||
|
||
interface GoogleMapProps {
|
||
places?: PlaceData[];
|
||
// ❌ center YOK
|
||
// ❌ zoom YOK
|
||
className?: string;
|
||
hoveredPlaceId?: string | null;
|
||
selectedPlaceId?: string | null;
|
||
activeDayId?: string | null;
|
||
onMarkerClick?: (placeId: string) => void;
|
||
onMarkerHover?: (placeId: string | null) => void; // ❌ dayId YOK
|
||
showPolyline?: boolean;
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 🔄 INTERACTION FLOW
|
||
|
||
### 1. User Hovers Timeline Place
|
||
|
||
```
|
||
Timeline Place Hover
|
||
↓
|
||
TripPlanner: setHoveredPlaceId(placeId)
|
||
↓
|
||
GoogleMap: hoveredPlaceId prop değişir
|
||
↓
|
||
Visual Update Effect tetiklenir
|
||
↓
|
||
Marker: setIcon (hover state)
|
||
Marker: setZIndex (999)
|
||
Marker: setLabel (16px)
|
||
```
|
||
|
||
**Önemli:**
|
||
- ❌ activeDayId DEĞİŞMEZ
|
||
- ❌ Marker yeniden oluşturulmaz
|
||
- ✅ Sadece görsel güncelleme
|
||
|
||
---
|
||
|
||
### 2. User Clicks Timeline Place
|
||
|
||
```
|
||
Timeline Place Click
|
||
↓
|
||
TripPlanner: setSelectedPlaceId(placeId)
|
||
↓
|
||
GoogleMap: selectedPlaceId prop değişir
|
||
↓
|
||
Visual Update Effect tetiklenir
|
||
↓
|
||
Marker: setIcon (selected state)
|
||
Marker: setZIndex (1000)
|
||
Marker: setAnimation (BOUNCE)
|
||
Marker: setLabel (16px)
|
||
```
|
||
|
||
**Önemli:**
|
||
- ❌ Marker yeniden oluşturulmaz
|
||
- ✅ Sadece görsel güncelleme
|
||
- ✅ 2 saniye sonra animation durur
|
||
|
||
---
|
||
|
||
### 3. User Opens Day Accordion
|
||
|
||
```
|
||
Timeline Day Accordion Open
|
||
↓
|
||
TripPlanner: setActiveDayId(dayId)
|
||
↓
|
||
GoogleMap: activeDayId prop değişir
|
||
↓
|
||
Visual Update Effect tetiklenir
|
||
↓
|
||
Marker: setVisible (dayId === activeDayId)
|
||
```
|
||
|
||
**Önemli:**
|
||
- ❌ Marker silinmez
|
||
- ✅ Sadece gizlenir/gösterilir
|
||
- ✅ Smooth visibility toggle
|
||
|
||
---
|
||
|
||
### 4. User Hovers Map Marker
|
||
|
||
```
|
||
Map Marker Hover
|
||
↓
|
||
Marker: mouseover event
|
||
↓
|
||
onMarkerHover(placeId) callback
|
||
↓
|
||
TripPlanner: setHoveredPlaceId(placeId)
|
||
↓
|
||
GoogleMap: hoveredPlaceId prop değişir
|
||
↓
|
||
Visual Update Effect tetiklenir
|
||
↓
|
||
Marker: setIcon (hover state)
|
||
```
|
||
|
||
**Önemli:**
|
||
- ❌ activeDayId DEĞİŞMEZ
|
||
- ✅ Sadece hoveredPlaceId değişir
|
||
- ✅ Timeline'da place highlight olur
|
||
|
||
---
|
||
|
||
### 5. User Clicks Map Marker
|
||
|
||
```
|
||
Map Marker Click
|
||
↓
|
||
Marker: click event
|
||
↓
|
||
onMarkerClick(placeId) callback
|
||
↓
|
||
TripPlanner: setSelectedPlaceId(placeId)
|
||
↓
|
||
GoogleMap: selectedPlaceId prop değişir
|
||
↓
|
||
Visual Update Effect tetiklenir
|
||
↓
|
||
Marker: setIcon (selected state)
|
||
Marker: setAnimation (BOUNCE)
|
||
↓
|
||
InfoWindow: open
|
||
Map: panTo marker
|
||
```
|
||
|
||
---
|
||
|
||
## ✅ BEST PRACTICES
|
||
|
||
### 1. Marker Lifecycle
|
||
|
||
✅ **DO:**
|
||
- Marker'ları SADECE places değiştiğinde oluştur
|
||
- Mevcut marker'ları useRef içinde sakla
|
||
- Görsel güncellemeler için ayrı effect kullan
|
||
- Event listener'ları marker creation sırasında ekle
|
||
|
||
❌ **DON'T:**
|
||
- Marker'ları hover/select'te yeniden oluşturma
|
||
- onMarkerClick/onMarkerHover'ı dependency'ye ekleme
|
||
- Her effect'te marker loop yapma
|
||
|
||
---
|
||
|
||
### 2. Center Management
|
||
|
||
✅ **DO:**
|
||
- hasCenteredRef kullan
|
||
- Center'ı sadece 1 kez ayarla
|
||
- Kullanıcı zoom/pan'i koru
|
||
|
||
❌ **DON'T:**
|
||
- Her places değişiminde fitBounds çağırma
|
||
- Kullanıcı zoom/pan'i resetleme
|
||
|
||
---
|
||
|
||
### 3. Visual Updates
|
||
|
||
✅ **DO:**
|
||
- setIcon, setVisible, setZIndex, setAnimation kullan
|
||
- Marker size/anchor'ı sabit tut
|
||
- State'e göre sadece color değiştir
|
||
|
||
❌ **DON'T:**
|
||
- Marker'ı yeniden oluşturma
|
||
- Size/anchor değiştirme (jitter)
|
||
- Gereksiz DOM manipulation
|
||
|
||
---
|
||
|
||
### 4. Performance
|
||
|
||
✅ **DO:**
|
||
- Effect dependency'lerini minimize et
|
||
- Marker recreation'ı önle
|
||
- Memory leak'leri temizle
|
||
|
||
❌ **DON'T:**
|
||
- Gereksiz effect tetikleme
|
||
- Marker'ları silmeden bırakma
|
||
- Çok fazla dependency ekleme
|
||
|
||
---
|
||
|
||
## 🎯 SONUÇ
|
||
|
||
GoogleMap component'i optimal marker lifecycle yönetimi ile:
|
||
|
||
✅ **Performans:**
|
||
- Marker recreation: Minimal (sadece places değişiminde)
|
||
- Effect execution: Optimize edilmiş
|
||
- Memory kullanımı: Stabil
|
||
|
||
✅ **Kullanıcı Deneyimi:**
|
||
- Smooth hover/select transitions
|
||
- Marker jitter YOK
|
||
- Map zoom/pan korunuyor
|
||
- Responsive interactions
|
||
|
||
✅ **Kod Kalitesi:**
|
||
- Clean separation of concerns
|
||
- Predictable lifecycle
|
||
- Easy to maintain
|
||
- Well documented
|
||
|
||
**GoogleMap component production-ready!** 🎉
|