Initial commit: Japan PWA guide
This commit is contained in:
231
src/pages/MapPage.tsx
Normal file
231
src/pages/MapPage.tsx
Normal file
@@ -0,0 +1,231 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user