232 lines
7.4 KiB
TypeScript
232 lines
7.4 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react';
|
||
import { MapContainer, TileLayer, Marker, Popup, useMap, Polyline } from 'react-leaflet';
|
||
import L from 'leaflet';
|
||
import { Place, GeoPosition } from '../types';
|
||
import scheduleData from '../data/schedule.json';
|
||
import 'leaflet/dist/leaflet.css';
|
||
|
||
interface MapPageProps {
|
||
places: Place[];
|
||
position: GeoPosition | null;
|
||
selectedPlace: Place | null;
|
||
onSelectPlace: (place: Place) => void;
|
||
showRouteForDay: number | null;
|
||
onClearRoute: () => void;
|
||
}
|
||
|
||
// Fix marker icons
|
||
delete (L.Icon.Default.prototype as any)._getIconUrl;
|
||
L.Icon.Default.mergeOptions({
|
||
iconRetinaUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon-2x.png',
|
||
iconUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon.png',
|
||
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-shadow.png',
|
||
});
|
||
|
||
const createIcon = (emoji: string, isSelected: boolean = false) => {
|
||
return L.divIcon({
|
||
className: 'custom-marker',
|
||
html: `
|
||
<div style="
|
||
font-size: ${isSelected ? '28px' : '22px'};
|
||
text-shadow: 0 2px 4px rgba(0,0,0,0.3);
|
||
transform: translate(-50%, -50%);
|
||
transition: all 0.2s;
|
||
${isSelected ? 'filter: drop-shadow(0 0 8px #FF6B6B);' : ''}
|
||
">
|
||
${emoji}
|
||
</div>
|
||
`,
|
||
iconSize: [40, 40],
|
||
iconAnchor: [20, 20],
|
||
});
|
||
};
|
||
|
||
const getCategoryEmoji = (category: string): string => {
|
||
const emojis: Record<string, string> = {
|
||
sight: '🏯',
|
||
restaurant: '🍜',
|
||
coffee: '☕',
|
||
snack: '🍡',
|
||
hotel: '🏨',
|
||
};
|
||
return emojis[category] || '📍';
|
||
};
|
||
|
||
function MapController({ center, zoom }: { center: [number, number]; zoom: number }) {
|
||
const map = useMap();
|
||
useEffect(() => {
|
||
map.flyTo(center, zoom, { duration: 0.5 });
|
||
}, [center, zoom, map]);
|
||
return null;
|
||
}
|
||
|
||
function LocationMarker({ position }: { position: GeoPosition | null }) {
|
||
if (!position) return null;
|
||
|
||
return (
|
||
<Marker
|
||
position={[position.lat, position.lng]}
|
||
icon={L.divIcon({
|
||
className: 'location-marker',
|
||
html: `
|
||
<div style="
|
||
width: 16px;
|
||
height: 16px;
|
||
background: #4285F4;
|
||
border: 3px solid white;
|
||
border-radius: 50%;
|
||
box-shadow: 0 2px 8px rgba(66, 133, 244, 0.5);
|
||
animation: pulse 2s infinite;
|
||
"></div>
|
||
`,
|
||
iconSize: [22, 22],
|
||
iconAnchor: [11, 11],
|
||
})}
|
||
/>
|
||
);
|
||
}
|
||
|
||
export function MapPage({ places, position, selectedPlace, onSelectPlace, showRouteForDay, onClearRoute }: MapPageProps) {
|
||
const [mapCenter, setMapCenter] = useState<[number, number]>([35.6762, 139.6503]);
|
||
const [mapZoom, setMapZoom] = useState(11);
|
||
const schedule = scheduleData as Record<string, any>;
|
||
|
||
// Get route for selected day
|
||
const routeCoordinates = useMemo(() => {
|
||
if (!showRouteForDay) return [];
|
||
const dayData = schedule[`day${showRouteForDay}`];
|
||
if (!dayData) return [];
|
||
|
||
const coords: [number, number][] = [];
|
||
dayData.schedule.forEach((item: any) => {
|
||
if (item.placeId) {
|
||
const place = places.find(p => p.id === item.placeId);
|
||
if (place) {
|
||
coords.push([place.lat, place.lng]);
|
||
}
|
||
}
|
||
});
|
||
return coords;
|
||
}, [showRouteForDay, places, schedule]);
|
||
|
||
// Update center when selected place changes
|
||
useEffect(() => {
|
||
if (selectedPlace) {
|
||
setMapCenter([selectedPlace.lat, selectedPlace.lng]);
|
||
setMapZoom(15);
|
||
}
|
||
}, [selectedPlace]);
|
||
|
||
// Update center for route
|
||
useEffect(() => {
|
||
if (routeCoordinates.length > 0) {
|
||
const bounds = L.latLngBounds(routeCoordinates);
|
||
const center = bounds.getCenter();
|
||
setMapCenter([center.lat, center.lng]);
|
||
setMapZoom(12);
|
||
}
|
||
}, [routeCoordinates]);
|
||
|
||
const visiblePlaces = useMemo(() => {
|
||
if (showRouteForDay) {
|
||
const dayData = schedule[`day${showRouteForDay}`];
|
||
if (!dayData) return places;
|
||
const placeIds = dayData.schedule
|
||
.filter((item: any) => item.placeId)
|
||
.map((item: any) => item.placeId);
|
||
return places.filter(p => placeIds.includes(p.id));
|
||
}
|
||
return places;
|
||
}, [places, showRouteForDay, schedule]);
|
||
|
||
return (
|
||
<div className="h-full relative">
|
||
<MapContainer
|
||
center={mapCenter}
|
||
zoom={mapZoom}
|
||
className="h-full w-full"
|
||
zoomControl={false}
|
||
>
|
||
<TileLayer
|
||
attribution='© <a href="https://www.openstreetmap.org/copyright">OSM</a>'
|
||
url="https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png"
|
||
/>
|
||
|
||
<MapController center={mapCenter} zoom={mapZoom} />
|
||
<LocationMarker position={position} />
|
||
|
||
{/* Route polyline */}
|
||
{routeCoordinates.length > 1 && (
|
||
<Polyline
|
||
positions={routeCoordinates}
|
||
color="#FF6B6B"
|
||
weight={4}
|
||
opacity={0.8}
|
||
dashArray="10, 10"
|
||
/>
|
||
)}
|
||
|
||
{/* Place markers */}
|
||
{visiblePlaces.map((place) => (
|
||
<Marker
|
||
key={place.id}
|
||
position={[place.lat, place.lng]}
|
||
icon={createIcon(getCategoryEmoji(place.category), selectedPlace?.id === place.id)}
|
||
eventHandlers={{
|
||
click: () => onSelectPlace(place),
|
||
}}
|
||
>
|
||
<Popup className="custom-popup">
|
||
<div className="p-1">
|
||
<h3 className="font-bold text-gray-900">{place.name}</h3>
|
||
<p className="text-xs text-gray-500">{place.nameJp}</p>
|
||
{place.day && (
|
||
<span className="inline-block mt-1 px-2 py-0.5 bg-[#FFE8E8] text-[#FF6B6B] text-xs rounded-full">
|
||
День {place.day}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</Popup>
|
||
</Marker>
|
||
))}
|
||
</MapContainer>
|
||
|
||
{/* Map controls */}
|
||
<div className="absolute top-4 right-4 z-[1000] flex flex-col gap-2">
|
||
{position && (
|
||
<button
|
||
onClick={() => {
|
||
setMapCenter([position.lat, position.lng]);
|
||
setMapZoom(15);
|
||
}}
|
||
className="w-10 h-10 bg-white rounded-full shadow-lg flex items-center justify-center"
|
||
>
|
||
<svg className="w-5 h-5 text-[#4285F4]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
||
</svg>
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{/* Route info bar */}
|
||
{showRouteForDay && (
|
||
<div className="absolute top-4 left-4 right-16 z-[1000]">
|
||
<div className="bg-white rounded-xl shadow-lg p-3 flex items-center justify-between">
|
||
<div>
|
||
<p className="text-sm font-semibold text-gray-900">Маршрут дня {showRouteForDay}</p>
|
||
<p className="text-xs text-gray-500">{visiblePlaces.length} мест</p>
|
||
</div>
|
||
<button
|
||
onClick={onClearRoute}
|
||
className="px-3 py-1.5 bg-gray-100 text-gray-600 rounded-lg text-sm font-medium"
|
||
>
|
||
Скрыть
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|