38980-vm/app-9w9pd00g5j41/GOOGLEMAP_ARCHITECTURE.md
2026-03-04 18:25:09 +00:00

14 KiB
Raw Permalink Blame History

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
  • isScriptLoaded state'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! 🎉