39343-vm/pages/HeritageMap.tsx
2026-03-27 12:21:43 +00:00

436 lines
26 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useState, useEffect, useRef } from 'react';
import { Button } from '../components/ui/Button';
import { Map as MapIcon, Loader2, Navigation, Compass, Search, List, X, MapPin, History, BookOpen, Star, MessageSquare, ArrowLeft, LocateFixed, Mountain, Landmark, Droplets, User, Info, Layout, Layers, Bot, Route, ChevronUp, ChevronDown, Satellite } from 'lucide-react';
import { HeritageService } from '../services/heritageService';
import { StorageService } from '../services/storageService';
import { HeritageSite, Review, ProvinceData } from '../types';
import { PROVINCES } from '../data/staticData';
import { useLanguage } from '../contexts/LanguageContext';
declare const L: any; // Leaflet global
// Satellite Map Tile URL (Esri World Imagery)
const SATELLITE_TILE_URL = 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}';
const SATELLITE_ATTRIBUTION = 'Tiles © Esri — Source: Esri, i-cubed, USDA, USGS, AEX, GeoEye, Getmapping, Aerogrid, IGN, IGP, UPR-EGP, and the GIS User Community';
const HeritageMap: React.FC = () => {
const { t, language } = useLanguage();
const [sites, setSites] = useState<HeritageSite[]>([]);
const [filteredSites, setFilteredSites] = useState<HeritageSite[]>([]);
const [filteredProvinces, setFilteredProvinces] = useState<ProvinceData[]>(PROVINCES);
const [searchQuery, setSearchQuery] = useState('');
const [selectedCategory, setSelectedCategory] = useState<string>('All');
// Selection State
const [selectedSite, setSelectedSite] = useState<HeritageSite | null>(null);
const [selectedProvince, setSelectedProvince] = useState<ProvinceData | null>(null);
const [loading, setLoading] = useState(true);
// Location
const [userPos, setUserPos] = useState<[number, number] | null>(null);
// Mode State
const [activeMode, setActiveMode] = useState<'heritage' | 'provinces'>('heritage');
const [isMobileListExpanded, setIsMobileListExpanded] = useState(false);
const mapContainerRef = useRef<HTMLDivElement>(null);
const mapInstanceRef = useRef<any>(null);
const markerClusterGroupRef = useRef<any>(null);
const provinceLayerRef = useRef<any>(null);
const userMarkerRef = useRef<any>(null);
const tileLayerRef = useRef<any>(null);
// --- LIVE LOCATION TRACKING ---
useEffect(() => {
if (navigator.geolocation) {
const watchId = navigator.geolocation.watchPosition(
(position) => {
const { latitude, longitude } = position.coords;
setUserPos([latitude, longitude]);
if (mapInstanceRef.current) {
if (userMarkerRef.current) {
userMarkerRef.current.setLatLng([latitude, longitude]);
} else {
const icon = L.divIcon({
className: 'user-location-marker',
html: `<div class="relative w-4 h-4"><div class="absolute inset-0 bg-blue-500 rounded-full animate-ping opacity-75"></div><div class="relative w-4 h-4 bg-blue-500 rounded-full border-2 border-white shadow-lg"></div></div>`,
iconSize: [16, 16],
iconAnchor: [8, 8]
});
userMarkerRef.current = L.marker([latitude, longitude], { icon, zIndexOffset: 1000 }).addTo(mapInstanceRef.current);
}
}
},
(error) => console.warn("Location error:", error),
{ enableHighAccuracy: true, timeout: 20000, maximumAge: 1000 }
);
return () => navigator.geolocation.clearWatch(watchId);
}
}, []);
// --- RUDRA CONTROL ---
useEffect(() => {
const handleRemoteControl = (e: any) => {
const { type, targetName } = e.detail;
if (type === 'province') {
const province = PROVINCES.find(p => p.name.toLowerCase().includes(targetName.toLowerCase()));
if (province) {
handleSelectProvince(province);
// Force open info tab if handled by AI
setSelectedProvince(province);
}
} else if (type === 'site') {
const site = sites.find(s => s.name.toLowerCase().includes(targetName.toLowerCase()));
if (site) {
handleSelectSite(site);
setSelectedSite(site);
}
} else if (type === 'reset') {
mapInstanceRef.current?.flyTo([28.3949, 84.1240], 7);
setSelectedSite(null);
setSelectedProvince(null);
}
};
window.addEventListener('rudraksha-map-control', handleRemoteControl);
return () => window.removeEventListener('rudraksha-map-control', handleRemoteControl);
}, [sites]);
useEffect(() => {
const loadSites = async () => {
setLoading(true);
const data = await HeritageService.getAllSites();
setSites(data);
setFilteredSites(data);
setLoading(false);
setTimeout(() => initMap(data), 100);
};
loadSites();
}, []);
// Filtering Logic
useEffect(() => {
const query = searchQuery.toLowerCase();
if (activeMode === 'heritage') {
let result = sites;
if (query) {
result = result.filter(site => (language === 'ne' ? site.nameNe : site.name).toLowerCase().includes(query));
}
if (selectedCategory !== 'All') {
result = result.filter(site => site.category === selectedCategory);
}
setFilteredSites(result);
if (mapInstanceRef.current) updateHeritageMarkers(result);
// Hide province markers
if (provinceLayerRef.current) provinceLayerRef.current.clearLayers();
} else {
// Province Mode
let result = PROVINCES;
if (query) {
result = result.filter(prov => (language === 'ne' ? prov.nepaliName : prov.name).toLowerCase().includes(query));
}
setFilteredProvinces(result);
if (mapInstanceRef.current) updateProvinceMarkers(result);
// Hide heritage markers
if (markerClusterGroupRef.current) markerClusterGroupRef.current.clearLayers();
}
}, [searchQuery, selectedCategory, sites, activeMode, language]);
const initMap = (initialSites: HeritageSite[]) => {
if (!mapContainerRef.current || mapInstanceRef.current || typeof L === 'undefined') return;
const map = L.map(mapContainerRef.current, { zoomControl: false }).setView([28.3949, 84.1240], 7);
mapInstanceRef.current = map;
L.control.zoom({ position: 'bottomright' }).addTo(map);
// Set Satellite Tile Layer
tileLayerRef.current = L.tileLayer(SATELLITE_TILE_URL, {
maxZoom: 19,
attribution: SATELLITE_ATTRIBUTION
}).addTo(map);
// Add Layer Groups
if (L.markerClusterGroup) {
markerClusterGroupRef.current = L.markerClusterGroup({ showCoverageOnHover: false, zoomToBoundsOnClick: true, removeOutsideVisibleBounds: true });
map.addLayer(markerClusterGroupRef.current);
} else {
markerClusterGroupRef.current = L.layerGroup().addTo(map);
}
provinceLayerRef.current = L.layerGroup().addTo(map);
// Initial Load
if (activeMode === 'heritage') updateHeritageMarkers(initialSites);
else updateProvinceMarkers(PROVINCES);
};
const handleSelectSite = (site: HeritageSite) => {
setSelectedProvince(null);
setSelectedSite(site);
setActiveMode('heritage');
setIsMobileListExpanded(false);
mapInstanceRef.current?.flyTo([site.latitude, site.longitude], 16, { duration: 1.5 });
};
const handleSelectProvince = (prov: ProvinceData) => {
setSelectedSite(null);
setSelectedProvince(prov);
setActiveMode('provinces');
setIsMobileListExpanded(false);
mapInstanceRef.current?.flyTo([prov.lat, prov.lng], 9, { duration: 1.5 });
};
const updateHeritageMarkers = (sitesData: HeritageSite[]) => {
if (!markerClusterGroupRef.current) return;
markerClusterGroupRef.current.clearLayers();
const newMarkers: any[] = [];
sitesData.forEach(site => {
const icon = L.divIcon({
className: 'custom-icon',
html: `<div class="w-12 h-12 rounded-full border-2 border-white shadow-lg overflow-hidden bg-white hover:scale-110 transition-transform"><img src="${site.imageUrl}" class="w-full h-full object-cover"/></div>`,
iconSize: [48, 48],
iconAnchor: [24, 24]
});
const marker = L.marker([site.latitude, site.longitude], { icon });
marker.on('click', () => handleSelectSite(site));
newMarkers.push(marker);
});
if (markerClusterGroupRef.current.addLayers) {
markerClusterGroupRef.current.addLayers(newMarkers);
} else {
newMarkers.forEach(m => markerClusterGroupRef.current.addLayer(m));
}
};
const updateProvinceMarkers = (provincesData: ProvinceData[]) => {
if (!provinceLayerRef.current) return;
provinceLayerRef.current.clearLayers();
provincesData.forEach(prov => {
// Same style as heritage markers now
const icon = L.divIcon({
html: `<div class="relative group cursor-pointer hover:scale-110 transition-transform"><div class="w-12 h-12 rounded-full border-2 border-white shadow-xl overflow-hidden bg-gradient-to-br ${prov.color} p-0.5"><img src="${prov.image}" class="w-full h-full object-cover rounded-full" /></div><div class="absolute -bottom-3 left-1/2 -translate-x-1/2 bg-white text-[10px] font-black px-2 py-0.5 rounded shadow-md whitespace-nowrap text-gray-900">${prov.name}</div></div>`,
className: 'bg-transparent',
iconSize: [48, 48],
iconAnchor: [24, 24]
});
const marker = L.marker([prov.lat, prov.lng], { icon });
marker.on('click', () => handleSelectProvince(prov));
marker.addTo(provinceLayerRef.current);
});
};
const categories = ['All', 'Temple', 'Stupa', 'Palace', 'Nature', 'Other'];
const localizeNumber = (value: string | number): string => {
if (language === 'en') return value.toString();
const str = value.toString();
const digits = ['', '१', '२', '३', '४', '५', '६', '७', '८', '९'];
return str.replace(/[0-9]/g, (m) => digits[parseInt(m)]);
};
return (
<div className="relative h-[calc(100dvh-85px)] md:h-[calc(100vh-85px)] w-full rounded-[2.5rem] overflow-hidden shadow-2xl border border-gray-200 dark:border-gray-800 bg-gray-900">
{/* MAP CONTAINER */}
<div ref={mapContainerRef} className="absolute inset-0 z-0" />
{/* LOADING OVERLAY */}
{loading && (
<div className="absolute inset-0 z-[500] bg-black/20 backdrop-blur-sm flex items-center justify-center">
<Loader2 className="animate-spin text-white w-12 h-12"/>
</div>
)}
{/* TOP CONTROLS (Floating Mode Toggle) */}
<div className="absolute top-4 left-1/2 -translate-x-1/2 z-[400] flex gap-1 p-1 bg-black/60 backdrop-blur-xl rounded-2xl shadow-xl border border-white/20 scale-90 md:scale-100 origin-top">
<button onClick={() => { setActiveMode('heritage'); setSearchQuery(''); setSelectedProvince(null); }} className={`px-5 py-2.5 rounded-xl text-xs font-black uppercase tracking-widest flex items-center gap-2 transition-all ${activeMode === 'heritage' ? 'bg-red-600 text-white shadow-lg' : 'text-gray-300 hover:text-white'}`}>
<Compass size={16}/> Heritage
</button>
<button onClick={() => { setActiveMode('provinces'); setSearchQuery(''); setSelectedSite(null); }} className={`px-5 py-2.5 rounded-xl text-xs font-black uppercase tracking-widest flex items-center gap-2 transition-all ${activeMode === 'provinces' ? 'bg-blue-600 text-white shadow-lg' : 'text-gray-300 hover:text-white'}`}>
<Layout size={16}/> Provinces
</button>
</div>
{/* SEARCH & LIST PANEL (Responsive: Sidebar on Desktop, Bottom Sheet on Mobile) */}
<div className={`
z-[400] flex flex-col gap-3 pointer-events-none transition-all duration-500 ease-out
fixed bottom-0 left-0 right-0 p-4 pb-20 md:pb-4
md:absolute md:top-6 md:left-6 md:bottom-6 md:w-80 md:p-0
${isMobileListExpanded ? 'h-[60dvh]' : 'h-auto'} md:h-auto
`}>
{/* Search Box */}
<div className="bg-white/90 dark:bg-gray-900/90 backdrop-blur-xl p-4 rounded-[2rem] shadow-2xl border border-white/20 pointer-events-auto space-y-3 shrink-0">
{/* Mobile Toggle Handle */}
<div className="w-12 h-1 bg-gray-300 dark:bg-gray-700 rounded-full mx-auto md:hidden" onClick={() => setIsMobileListExpanded(!isMobileListExpanded)}></div>
<div className="relative">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-400" size={18}/>
<input
value={searchQuery}
onChange={e => { setSearchQuery(e.target.value); setIsMobileListExpanded(true); }}
onFocus={() => setIsMobileListExpanded(true)}
placeholder={activeMode === 'heritage' ? t("Search heritage...", "Search heritage...") : t("Search provinces...", "Search provinces...")}
className="w-full pl-10 pr-4 py-3 bg-gray-100 dark:bg-gray-800 rounded-xl text-sm font-bold outline-none focus:ring-2 ring-red-500/50 dark:text-white transition-all"
/>
</div>
{activeMode === 'heritage' && (
<div className="flex gap-2 overflow-x-auto scrollbar-none pb-1">
{categories.map(cat => (
<button key={cat} onClick={() => setSelectedCategory(cat)} className={`px-3 py-1.5 rounded-lg text-[10px] font-black uppercase tracking-wider whitespace-nowrap transition-all border ${selectedCategory === cat ? 'bg-red-500 text-white border-red-500' : 'bg-gray-50 dark:bg-gray-800 text-gray-500 border-gray-200 dark:border-gray-700'}`}>
{cat}
</button>
))}
</div>
)}
</div>
{/* Results List */}
<div className={`flex-1 overflow-hidden pointer-events-none transition-opacity duration-300 ${isMobileListExpanded ? 'opacity-100' : 'opacity-0 md:opacity-100'}`}>
<div className="h-full bg-white/90 dark:bg-gray-900/90 backdrop-blur-xl rounded-[2rem] shadow-2xl border border-white/20 pointer-events-auto overflow-y-auto custom-scrollbar p-3 space-y-2">
{activeMode === 'heritage' ? (
filteredSites.map(site => (
<div key={site.id} onClick={() => handleSelectSite(site)} className={`flex items-center gap-3 p-2 rounded-xl cursor-pointer transition-all border-2 ${selectedSite?.id === site.id ? 'bg-red-50 dark:bg-red-900/20 border-red-500' : 'bg-transparent border-transparent hover:bg-gray-100 dark:hover:bg-gray-800'}`}>
<img src={site.imageUrl} className="w-12 h-12 rounded-lg object-cover bg-gray-200" alt=""/>
<div className="min-w-0">
<h4 className="text-xs font-black uppercase text-gray-900 dark:text-white truncate">{language === 'ne' ? (site.nameNe || site.name) : site.name}</h4>
<p className="text-[10px] text-gray-500 truncate">{site.category} {site.region}</p>
</div>
</div>
))
) : (
filteredProvinces.map(prov => (
<div key={prov.id} onClick={() => handleSelectProvince(prov)} className={`flex items-center gap-3 p-2 rounded-xl cursor-pointer transition-all border-2 ${selectedProvince?.id === prov.id ? 'bg-blue-50 dark:bg-blue-900/20 border-blue-500' : 'bg-transparent border-transparent hover:bg-gray-100 dark:hover:bg-gray-800'}`}>
<div className={`w-12 h-12 rounded-lg bg-gradient-to-br ${prov.color} p-0.5`}><img src={prov.image} className="w-full h-full object-cover rounded-md"/></div>
<div className="min-w-0">
<h4 className="text-xs font-black uppercase text-gray-900 dark:text-white truncate">{language === 'ne' ? prov.nepaliName : prov.name}</h4>
<p className="text-[10px] text-gray-500 truncate">{localizeNumber(prov.districts)} Districts</p>
</div>
</div>
))
)}
</div>
</div>
</div>
{/* DETAILS PANEL (Unified for both Modes) */}
{(selectedSite || selectedProvince) && (
<div className={`
fixed inset-0 z-[500] md:absolute md:inset-auto md:top-6 md:right-6 md:bottom-6 md:w-[400px] md:z-[401]
pointer-events-none flex flex-col items-end
`}>
<div className="h-full w-full bg-white/95 dark:bg-gray-950/95 backdrop-blur-2xl md:rounded-[2.5rem] shadow-[0_20px_60px_rgba(0,0,0,0.5)] border-0 md:border border-white/20 pointer-events-auto overflow-hidden flex flex-col animate-in slide-in-from-bottom-10 md:slide-in-from-right-20 duration-300">
{/* Panel Header Image */}
<div className="relative h-48 md:h-48 shrink-0">
<img
src={selectedSite?.imageUrl || selectedProvince?.image}
className="w-full h-full object-cover"
alt="Header"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent"></div>
<button onClick={() => { setSelectedSite(null); setSelectedProvince(null); }} className="absolute top-4 right-4 p-2 bg-black/40 text-white rounded-full hover:bg-red-600 transition-colors backdrop-blur-md z-10"><X size={20}/></button>
<div className="absolute bottom-4 left-6 right-6">
<span className={`inline-block px-3 py-1 rounded-full text-[10px] font-black uppercase tracking-widest text-white mb-2 shadow-lg ${selectedSite ? 'bg-red-600' : 'bg-blue-600'}`}>
{selectedSite ? selectedSite.category : `Province ${localizeNumber(selectedProvince!.id)}`}
</span>
<h2 className="text-2xl font-black text-white leading-none italic uppercase tracking-tighter drop-shadow-xl">
{selectedSite ? (language === 'ne' ? selectedSite.nameNe : selectedSite.name) : (language === 'ne' ? selectedProvince?.nepaliName : selectedProvince?.name)}
</h2>
</div>
</div>
{/* Content Body */}
<div className="flex-1 overflow-y-auto custom-scrollbar bg-gray-50/50 dark:bg-black/20 flex flex-col">
<div className="p-6 space-y-6">
{/* Province Specific Stats Grid */}
{selectedProvince ? (
<>
<div className="grid grid-cols-2 gap-3">
<div className="bg-white dark:bg-gray-900 p-3 rounded-2xl border border-gray-100 dark:border-gray-800 shadow-sm">
<p className="text-[10px] text-gray-400 font-bold uppercase">Capital</p>
<p className="font-black text-sm dark:text-white">{language === 'ne' ? selectedProvince.capitalNe : selectedProvince.capital}</p>
</div>
<div className="bg-white dark:bg-gray-900 p-3 rounded-2xl border border-gray-100 dark:border-gray-800 shadow-sm">
<p className="text-[10px] text-gray-400 font-bold uppercase">Area</p>
<p className="font-black text-sm dark:text-white">{localizeNumber(selectedProvince.area)}</p>
</div>
</div>
{/* Capital Satellite Uplink (Static Visual Placeholder) */}
<div className="bg-black rounded-2xl overflow-hidden shadow-lg border-2 border-gray-800 relative group">
<div className="absolute top-2 left-2 z-10 bg-black/60 backdrop-blur-sm px-2 py-1 rounded text-[9px] font-black text-green-400 uppercase tracking-widest flex items-center gap-1 border border-green-500/30">
<Satellite size={10} className="animate-pulse"/> Capital Uplink
</div>
<div className="h-32 bg-gray-800 relative">
{/* Using a generic high-res satellite looking image for visual effect since real-time requires key */}
<img
src="https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/12/1744/3028"
className="w-full h-full object-cover opacity-80 group-hover:scale-110 transition-transform duration-1000"
alt="Satellite View"
/>
<div className="absolute inset-0 bg-scanline pointer-events-none"></div>
</div>
</div>
</>
) : (
<div className="flex items-center gap-2 bg-white dark:bg-gray-900 p-3 rounded-2xl border border-gray-100 dark:border-gray-800 shadow-sm">
<MapPin size={16} className="text-red-500"/>
<p className="font-black text-sm dark:text-white">{selectedSite?.region}</p>
</div>
)}
<div className="bg-white dark:bg-gray-900 p-5 rounded-[2rem] border border-gray-100 dark:border-gray-800 shadow-sm">
<h3 className="text-xs font-black uppercase text-gray-400 mb-2 tracking-widest flex items-center gap-2"><Info size={14}/> About</h3>
<p className="text-sm font-medium text-gray-600 dark:text-gray-300 leading-relaxed">
{selectedSite ? (language === 'ne' ? selectedSite.descriptionNe : selectedSite.description) : (language === 'ne' ? selectedProvince?.descriptionNe : selectedProvince?.description)}
</p>
</div>
{selectedSite ? (
<Button
onClick={() => window.open(`https://www.google.com/maps/dir/?api=1&destination=${selectedSite.latitude},${selectedSite.longitude}`, '_blank')}
className="w-full h-12 bg-red-600 hover:bg-red-700 text-white rounded-xl font-black uppercase tracking-widest text-xs shadow-xl shadow-red-600/20"
>
<Navigation size={16} className="mr-2"/> Get Directions
</Button>
) : (
<div className="space-y-4">
<div className="bg-white dark:bg-gray-900 p-5 rounded-[2rem] border border-gray-100 dark:border-gray-800 shadow-sm">
<h3 className="text-xs font-black uppercase text-gray-400 mb-3 tracking-widest flex items-center gap-2"><Mountain size={14} className="text-green-500"/> Attractions</h3>
<ul className="space-y-2">
{(language === 'en' ? selectedProvince!.attractions : selectedProvince!.attractionsNe).map((attr, idx) => (
<li key={idx} className="flex items-center gap-2 text-sm font-bold text-gray-700 dark:text-gray-300">
<div className="w-1.5 h-1.5 rounded-full bg-green-500"></div> {attr}
</li>
))}
</ul>
</div>
<div className="bg-white dark:bg-gray-900 p-5 rounded-[2rem] border border-gray-100 dark:border-gray-800 shadow-sm">
<h3 className="text-xs font-black uppercase text-gray-400 mb-2 tracking-widest flex items-center gap-2"><User size={14} className="text-purple-500"/> Languages</h3>
<p className="text-sm font-bold text-gray-700 dark:text-gray-300">{language === 'en' ? selectedProvince!.mainLanguages : selectedProvince!.mainLanguagesNe}</p>
</div>
</div>
)}
</div>
</div>
</div>
</div>
)}
{/* Locate Button */}
<button onClick={() => { if(userPos && mapInstanceRef.current) mapInstanceRef.current.flyTo(userPos, 14); }} className="absolute bottom-20 md:bottom-6 right-6 md:right-[440px] z-[390] p-3 bg-black/60 backdrop-blur-md rounded-full shadow-xl hover:scale-110 transition-transform text-white border border-white/20">
<LocateFixed size={24}/>
</button>
<style>{`
.bg-scanline {
background: linear-gradient(to bottom, rgba(255,255,255,0), rgba(255,255,255,0) 50%, rgba(0,0,0,0.2) 50%, rgba(0,0,0,0.2));
background-size: 100% 4px;
}
`}</style>
</div>
);
};
export default HeritageMap;