miniperplx/components/interactive-maps.tsx

235 lines
6.0 KiB
TypeScript

import React, { useEffect, useRef, useCallback } from 'react';
import mapboxgl from 'mapbox-gl';
import 'mapbox-gl/dist/mapbox-gl.css';
import { cn } from "@/lib/utils";
interface Location {
lat: number;
lng: number;
}
interface Photo {
thumbnail: string;
small: string;
medium: string;
large: string;
original: string;
caption?: string;
}
interface Place {
name: string;
location: Location;
place_id: string;
vicinity: string;
rating?: number;
reviews_count?: number;
price_level?: string;
description?: string;
photos?: Photo[];
is_closed?: boolean;
next_open_close?: string;
type?: string;
cuisine?: string;
source?: string;
phone?: string;
website?: string;
hours?: string[];
timezone?: string;
}
mapboxgl.accessToken = process.env.NEXT_PUBLIC_MAPBOX_TOKEN || '';
interface InteractiveMapProps {
center: Location;
places: Place[];
selectedPlace: Place | null;
onPlaceSelect: (place: Place | null) => void;
className?: string;
viewMode?: 'map' | 'list';
}
const InteractiveMap: React.FC<InteractiveMapProps> = ({
center,
places,
selectedPlace,
onPlaceSelect,
className,
viewMode = 'map'
}) => {
const mapContainerRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<mapboxgl.Map | null>(null);
const markersRef = useRef<{ [key: string]: mapboxgl.Marker }>({});
// Handler for marker clicks
const handleMarkerClick = useCallback((place: Place) => {
onPlaceSelect(place);
}, [onPlaceSelect]);
// Initialize map
useEffect(() => {
if (!mapContainerRef.current) return;
mapRef.current = new mapboxgl.Map({
container: mapContainerRef.current,
style: 'mapbox://styles/mapbox/light-v11',
center: [center.lng, center.lat],
zoom: 14,
attributionControl: false,
});
const map = mapRef.current;
// Add minimal controls
map.addControl(
new mapboxgl.NavigationControl({ showCompass: false, showZoom: true }),
'bottom-right'
);
// Compact attribution
map.addControl(
new mapboxgl.AttributionControl({ compact: true }),
'bottom-left'
);
return () => {
Object.values(markersRef.current).forEach(marker => marker.remove());
map.remove();
};
}, [center.lat, center.lng]);
// Update markers
useEffect(() => {
if (!mapRef.current) return;
// Clear existing markers
Object.values(markersRef.current).forEach(marker => marker.remove());
markersRef.current = {};
// Create markers with click handlers
places.forEach((place, index) => {
const isSelected = selectedPlace?.place_id === place.place_id;
// Create marker element
const el = document.createElement('div');
el.className = cn(
'w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium transition-all duration-300 cursor-pointer shadow-md',
isSelected
? 'bg-black text-white scale-110'
: 'bg-white text-black hover:scale-105'
);
el.style.border = '2px solid currentColor';
el.innerHTML = `${index + 1}`;
// Create and add marker
const marker = new mapboxgl.Marker({
element: el,
anchor: 'center',
})
.setLngLat([place.location.lng, place.location.lat])
.addTo(mapRef.current!);
// Add click handler
el.addEventListener('click', (e) => {
e.stopPropagation(); // Prevent map click
handleMarkerClick(place);
});
// Store marker reference
markersRef.current[place.place_id] = marker;
});
}, [places, selectedPlace, handleMarkerClick]);
// Handle map click to deselect
useEffect(() => {
const map = mapRef.current;
if (!map) return;
const handleMapClick = (e: mapboxgl.MapMouseEvent) => {
// Check if click was on a marker
const clickedMarker = Object.values(markersRef.current).some(marker => {
const markerEl = marker.getElement();
return e.originalEvent.target === markerEl || markerEl.contains(e.originalEvent.target as Node);
});
// If click wasn't on a marker, deselect
if (!clickedMarker) {
onPlaceSelect(null);
}
};
map.on('click', handleMapClick);
return () => {
map.off('click', handleMapClick);
};
}, [onPlaceSelect]);
// Fly to selected place with proper padding for list view
useEffect(() => {
if (!mapRef.current || !selectedPlace) return;
const map = mapRef.current;
const { clientWidth, clientHeight } = document.documentElement;
// Calculate the actual width of list view (60% of viewport height in list mode)
const listHeight = viewMode === 'list' ? clientHeight * 0.6 : 0;
// Set padding based on view mode
const padding = {
top: viewMode === 'list' ? listHeight : 50,
bottom: 50,
left: 50,
right: 50
};
// Get coordinates of the target location
const coordinates: [number, number] = [selectedPlace.location.lng, selectedPlace.location.lat];
// Calculate the optimal zoom level
const currentZoom = map.getZoom();
const targetZoom = currentZoom < 15 ? 15 : currentZoom;
// Fly to location with padding
map.flyTo({
center: coordinates,
zoom: targetZoom,
padding: padding,
duration: 1500,
essential: true
});
// Ensure padding is maintained after animation
setTimeout(() => {
if (mapRef.current) {
mapRef.current.setPadding(padding);
}
}, 1600);
}, [selectedPlace, viewMode]);
// Update map padding when view mode changes
useEffect(() => {
if (!mapRef.current) return;
const { clientHeight } = document.documentElement;
const listHeight = viewMode === 'list' ? clientHeight * 0.6 : 0;
const padding = {
top: viewMode === 'list' ? listHeight : 50,
bottom: 50,
left: 50,
right: 50
};
mapRef.current.setPadding(padding);
}, [viewMode]);
return (
<div className={cn("w-full h-full relative z-0", className)}>
<div ref={mapContainerRef} className="w-full h-full" />
</div>
);
};
export default InteractiveMap;