Jaithra Polavarapu
feat(hf): deploy the full redesigned frontend (keep in-process inference)
965ef8f | 'use client' | |
| import { useLoadScript, GoogleMap, Marker, InfoWindow } from '@react-google-maps/api' | |
| import { useState, useCallback, useEffect } from 'react' | |
| import { MapPin } from 'lucide-react' | |
| import type { OutbreakReport } from '@/lib/outbreakReport' | |
| const libraries: ('places' | 'drawing' | 'geometry' | 'visualization')[] = ['places'] | |
| interface GoogleMapProps { | |
| reports?: OutbreakReport[] | |
| onMapClick?: (lat: number, lng: number) => void | |
| center?: { lat: number; lng: number } | |
| zoom?: number | |
| /** When false, hides the floating “click map” hint (parent may show its own). Default true. */ | |
| showMapClickHint?: boolean | |
| /** Called when the Google Map instance is ready (e.g. to trigger resize after layout changes). */ | |
| onMapReady?: (map: google.maps.Map) => void | |
| /** Maps API fullscreen control; set false when the parent provides its own fullscreen. Default true. */ | |
| fullscreenControl?: boolean | |
| } | |
| const mapContainerStyle = { | |
| width: '100%', | |
| height: '100%', | |
| } | |
| const defaultCenter = { | |
| lat: 39.8283, // Center of US | |
| lng: -98.5795, | |
| } | |
| const defaultZoom = 4 | |
| export default function GoogleMapComponent({ | |
| reports = [], | |
| onMapClick, | |
| center = defaultCenter, | |
| zoom = defaultZoom, | |
| showMapClickHint = true, | |
| onMapReady, | |
| fullscreenControl = true, | |
| }: GoogleMapProps) { | |
| const [selectedReport, setSelectedReport] = useState<OutbreakReport | null>(null) | |
| const [mapCenter, setMapCenter] = useState(center) | |
| const [loadingTimeout, setLoadingTimeout] = useState(false) | |
| const apiKey = process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY || '' | |
| const { isLoaded, loadError } = useLoadScript({ | |
| googleMapsApiKey: apiKey, | |
| libraries, | |
| }) | |
| // Add timeout to prevent infinite loading | |
| useEffect(() => { | |
| if (!isLoaded && !loadError && apiKey) { | |
| const timer = setTimeout(() => { | |
| setLoadingTimeout(true) | |
| }, 8000) // 8 second timeout | |
| return () => clearTimeout(timer) | |
| } else { | |
| setLoadingTimeout(false) | |
| } | |
| }, [isLoaded, loadError, apiKey]) | |
| const handleMapClick = useCallback( | |
| (e: google.maps.MapMouseEvent) => { | |
| if (e.latLng && onMapClick) { | |
| onMapClick(e.latLng.lat(), e.latLng.lng()) | |
| } | |
| }, | |
| [onMapClick] | |
| ) | |
| const handleMapLoad = useCallback( | |
| (map: google.maps.Map) => { | |
| onMapReady?.(map) | |
| }, | |
| [onMapReady] | |
| ) | |
| const getSeverityColor = (severity: string) => { | |
| switch (severity) { | |
| case 'high': | |
| return '#ef4444' | |
| case 'medium': | |
| return '#f97316' | |
| case 'low': | |
| return '#eab308' | |
| default: | |
| return '#64748b' | |
| } | |
| } | |
| // Create marker icons - only create when map is loaded | |
| const createMarkerIcon = useCallback((color: string) => { | |
| if (!isLoaded || typeof google === 'undefined' || !google.maps) { | |
| return undefined | |
| } | |
| // Create a canvas-based circle icon - most reliable method | |
| const canvas = document.createElement('canvas') | |
| canvas.width = 24 | |
| canvas.height = 24 | |
| const ctx = canvas.getContext('2d') | |
| if (!ctx) return undefined | |
| // Draw filled circle | |
| ctx.beginPath() | |
| ctx.arc(12, 12, 10, 0, 2 * Math.PI) | |
| ctx.fillStyle = color | |
| ctx.fill() | |
| // Draw white border | |
| ctx.strokeStyle = '#ffffff' | |
| ctx.lineWidth = 3 | |
| ctx.stroke() | |
| const dataUrl = canvas.toDataURL() | |
| return { | |
| url: dataUrl, | |
| scaledSize: new google.maps.Size(24, 24), | |
| anchor: new google.maps.Point(12, 12), | |
| } | |
| }, [isLoaded]) | |
| if (loadError || loadingTimeout) { | |
| return ( | |
| <div className="flex h-full min-h-[280px] w-full items-center justify-center rounded-xl border-2 border-slate-200 bg-slate-50 sm:min-h-[400px]"> | |
| <div className="text-center p-6"> | |
| <MapPin className="w-12 h-12 text-slate-400 mx-auto mb-4" /> | |
| <p className="text-slate-700 font-semibold mb-2 text-lg">Map Unavailable</p> | |
| <p className="text-slate-500 text-sm mb-1"> | |
| {apiKey | |
| ? 'Google Maps API key may be invalid or restricted' | |
| : 'Google Maps API key not configured'} | |
| </p> | |
| <p className="text-slate-400 text-xs mt-4">Outbreak reporting will work once the map loads</p> | |
| </div> | |
| </div> | |
| ) | |
| } | |
| if (!isLoaded) { | |
| return ( | |
| <div className="flex h-full min-h-[280px] w-full items-center justify-center rounded-xl border-2 border-primary-200 bg-primary-50 sm:min-h-[400px]"> | |
| <div className="text-center"> | |
| <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto mb-4"></div> | |
| <p className="text-primary-600 font-semibold">Loading Google Maps...</p> | |
| {!apiKey && ( | |
| <p className="text-primary-500 text-xs mt-2">No API key configured</p> | |
| )} | |
| </div> | |
| </div> | |
| ) | |
| } | |
| return ( | |
| <div className="relative h-full min-h-0 w-full overflow-hidden rounded-xl"> | |
| <style dangerouslySetInnerHTML={{ | |
| __html: ` | |
| /* Hide Google Maps watermark and "For development purposes only" text */ | |
| .gm-style-cc, | |
| .gm-style-cc div, | |
| .gm-style-cc a, | |
| a[href^="https://developers.google.com/maps"], | |
| .gm-bundled-control .gm-style-cc, | |
| div[title*="For development purposes only"], | |
| div[aria-label*="For development purposes only"] { | |
| display: none !important; | |
| visibility: hidden !important; | |
| opacity: 0 !important; | |
| height: 0 !important; | |
| width: 0 !important; | |
| overflow: hidden !important; | |
| } | |
| /* Hide default POI marker images only (avoid div[style*="place"] etc. — it breaks map controls) */ | |
| .gm-style img[src*="poi"], | |
| .gm-style img[src*="place"], | |
| .gm-style img[src*="marker"][src*="default"] { | |
| display: none !important; | |
| visibility: hidden !important; | |
| opacity: 0 !important; | |
| height: 0 !important; | |
| width: 0 !important; | |
| pointer-events: none !important; | |
| } | |
| ` | |
| }} /> | |
| <GoogleMap | |
| mapContainerStyle={mapContainerStyle} | |
| center={mapCenter} | |
| zoom={zoom} | |
| onLoad={handleMapLoad} | |
| onClick={handleMapClick} | |
| options={{ | |
| disableDefaultUI: false, | |
| zoomControl: true, | |
| streetViewControl: false, | |
| mapTypeControl: true, | |
| fullscreenControl, | |
| gestureHandling: 'greedy', | |
| clickableIcons: false, // Disable clickable POI icons | |
| restriction: { | |
| latLngBounds: { | |
| north: 49.38, | |
| south: 24.40, | |
| west: -125.0, | |
| east: -66.93, | |
| }, | |
| strictBounds: false, | |
| }, | |
| styles: [ | |
| { | |
| featureType: 'poi', | |
| elementType: 'all', | |
| stylers: [{ visibility: 'off' }], | |
| }, | |
| { | |
| featureType: 'poi', | |
| elementType: 'labels', | |
| stylers: [{ visibility: 'off' }], | |
| }, | |
| { | |
| featureType: 'poi', | |
| elementType: 'geometry', | |
| stylers: [{ visibility: 'off' }], | |
| }, | |
| { | |
| featureType: 'poi.business', | |
| elementType: 'all', | |
| stylers: [{ visibility: 'off' }], | |
| }, | |
| { | |
| featureType: 'poi.attraction', | |
| elementType: 'all', | |
| stylers: [{ visibility: 'off' }], | |
| }, | |
| { | |
| featureType: 'poi.place_of_worship', | |
| elementType: 'all', | |
| stylers: [{ visibility: 'off' }], | |
| }, | |
| { | |
| featureType: 'poi.school', | |
| elementType: 'all', | |
| stylers: [{ visibility: 'off' }], | |
| }, | |
| { | |
| featureType: 'poi.sports_complex', | |
| elementType: 'all', | |
| stylers: [{ visibility: 'off' }], | |
| }, | |
| ], | |
| }} | |
| > | |
| {reports.map((report) => { | |
| const color = getSeverityColor(report.severity) | |
| // Create icon directly here to ensure it's created when map is ready | |
| if (!isLoaded || typeof google === 'undefined' || !google.maps) { | |
| return null | |
| } | |
| // Create canvas icon on the fly | |
| const canvas = document.createElement('canvas') | |
| canvas.width = 24 | |
| canvas.height = 24 | |
| const ctx = canvas.getContext('2d') | |
| if (!ctx) return null | |
| // Draw colored circle | |
| ctx.beginPath() | |
| ctx.arc(12, 12, 10, 0, 2 * Math.PI) | |
| ctx.fillStyle = color | |
| ctx.fill() | |
| ctx.strokeStyle = '#ffffff' | |
| ctx.lineWidth = 3 | |
| ctx.stroke() | |
| const iconUrl = canvas.toDataURL() | |
| return ( | |
| <Marker | |
| key={report.id} | |
| position={{ lat: report.lat, lng: report.lng }} | |
| icon={{ | |
| url: iconUrl, | |
| scaledSize: new google.maps.Size(24, 24), | |
| anchor: new google.maps.Point(12, 12), | |
| }} | |
| clickable={true} | |
| cursor="pointer" | |
| title={`${report.crop} - ${report.disease} (${report.severity} severity)`} | |
| onClick={() => { | |
| setSelectedReport(report) | |
| }} | |
| animation={google.maps.Animation.DROP} | |
| /> | |
| ) | |
| })} | |
| {selectedReport && ( | |
| <InfoWindow | |
| position={{ lat: selectedReport.lat, lng: selectedReport.lng }} | |
| onCloseClick={() => setSelectedReport(null)} | |
| options={{ | |
| pixelOffset: new google.maps.Size(0, -10), | |
| }} | |
| > | |
| <div className="p-3 min-w-[min(18rem,calc(100vw-3rem))] max-w-[min(300px,calc(100vw-2rem))]"> | |
| <div className="flex items-start justify-between mb-2"> | |
| <h3 className="font-bold text-lg text-slate-900"> | |
| {selectedReport.crop.charAt(0).toUpperCase() + selectedReport.crop.slice(1)} - {selectedReport.disease} | |
| </h3> | |
| <button | |
| onClick={() => setSelectedReport(null)} | |
| className="text-slate-400 hover:text-slate-600 ml-2" | |
| aria-label="Close" | |
| > | |
| × | |
| </button> | |
| </div> | |
| <div className="mb-3"> | |
| <span | |
| className={`inline-block px-3 py-1 rounded-full text-xs font-bold ${ | |
| selectedReport.severity === 'high' | |
| ? 'bg-red-100 text-red-800 border-2 border-red-300' | |
| : selectedReport.severity === 'medium' | |
| ? 'bg-orange-100 text-orange-800 border-2 border-orange-300' | |
| : 'bg-yellow-100 text-yellow-800 border-2 border-yellow-300' | |
| }`} | |
| > | |
| {selectedReport.severity.toUpperCase()} SEVERITY | |
| </span> | |
| </div> | |
| {selectedReport.description && ( | |
| <p className="text-sm text-slate-700 mb-3 leading-relaxed"> | |
| {selectedReport.description} | |
| </p> | |
| )} | |
| {selectedReport.reporterVerified !== undefined && ( | |
| <div className="mb-3"> | |
| <span | |
| className={`inline-block text-xs font-bold px-2.5 py-1 rounded-full border ${ | |
| selectedReport.reporterVerified | |
| ? 'bg-primary-100 text-primary-900 border-primary-300' | |
| : 'bg-slate-100 text-slate-700 border-slate-300' | |
| }`} | |
| > | |
| {selectedReport.reporterVerified ? 'Verified farmer report' : 'Unverified farmer report'} | |
| </span> | |
| </div> | |
| )} | |
| <div className="border-t border-slate-200 pt-2 mt-2"> | |
| <p className="text-xs text-slate-500"> | |
| <span className="font-semibold">Location:</span> {selectedReport.lat.toFixed(4)}, {selectedReport.lng.toFixed(4)} | |
| </p> | |
| <p className="text-xs text-slate-500 mt-1"> | |
| <span className="font-semibold">Reported:</span> {new Date(selectedReport.date).toLocaleString()} | |
| </p> | |
| </div> | |
| </div> | |
| </InfoWindow> | |
| )} | |
| </GoogleMap> | |
| {/* Click instruction overlay */} | |
| {showMapClickHint && ( | |
| <div className="absolute bottom-3 left-3 right-3 sm:left-6 sm:right-auto sm:bottom-6 bg-gradient-to-r from-primary-600 to-primary-700 text-white px-3 py-2.5 sm:px-5 sm:py-3 rounded-lg shadow-xl z-10 border border-primary-800 pointer-events-none"> | |
| <p className="text-xs sm:text-sm font-semibold flex items-center gap-2"> | |
| <MapPin className="w-4 h-4 sm:w-5 sm:h-5 shrink-0" /> | |
| Tap or click the map to report an outbreak | |
| </p> | |
| </div> | |
| )} | |
| </div> | |
| ) | |
| } | |