Initial commit: Japan PWA guide

This commit is contained in:
Cosmo
2026-03-21 04:59:39 +00:00
commit 7db42fd784
36 changed files with 5705 additions and 0 deletions

231
src/pages/MapPage.tsx Normal file
View 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='&copy; <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>
);
}