14 KiB
14 KiB
GoogleMap Component - Final Architecture
📐 COMPONENT MİMARİSİ
State Management
// 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
isScriptLoadedstate'ini güncelle- Error handling
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
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
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
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
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
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)
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
// 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
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! 🎉