cropintel / components /USOutbreakMap.tsx
Jaithra Polavarapu
feat(hf): deploy the full redesigned frontend (keep in-process inference)
965ef8f
Raw
History Blame Contribute Delete
14.8 kB
'use client'
import { useState, useEffect, useRef, useCallback } from 'react'
import dynamic from 'next/dynamic'
import { MapPin, AlertTriangle, X, Save, Maximize2, Minimize2 } from 'lucide-react'
import type { OutbreakReport } from '@/lib/outbreakReport'
// Dynamically import Google Maps component to avoid SSR issues
const GoogleMapComponent = dynamic(() => import('./GoogleMap'), {
ssr: false,
loading: () => (
<div className="flex h-full min-h-[280px] w-full items-center justify-center rounded-xl border border-field-soil/10 bg-white sm:min-h-[400px]">
<div className="px-4 text-center">
<div className="mx-auto mb-4 h-12 w-12 animate-spin rounded-full border-b-2 border-primary-700"></div>
<p className="text-sm font-semibold text-primary-800 sm:text-base">Loading map...</p>
</div>
</div>
),
})
interface USOutbreakMapProps {
reports?: OutbreakReport[]
onReportSubmit?: (report: OutbreakReport) => void
/** Reporter status for new submissions from this browser */
reporterVerified: boolean
}
function triggerMapResize(map: google.maps.Map | null) {
if (!map || typeof google === 'undefined') return
window.setTimeout(() => {
google.maps.event.trigger(map, 'resize')
}, 120)
}
function getFullscreenElement(): Element | null {
const doc = document as Document & {
webkitFullscreenElement?: Element | null
}
return document.fullscreenElement ?? doc.webkitFullscreenElement ?? null
}
async function requestElementFullscreen(el: HTMLElement): Promise<boolean> {
const anyEl = el as HTMLElement & {
webkitRequestFullscreen?: () => void
}
try {
if (el.requestFullscreen) {
await el.requestFullscreen()
return true
}
} catch {
/* try webkit */
}
try {
if (anyEl.webkitRequestFullscreen) {
anyEl.webkitRequestFullscreen()
return true
}
} catch {
/* fall through */
}
return false
}
async function exitDocumentFullscreen(): Promise<void> {
const doc = document as Document & { webkitExitFullscreen?: () => void }
try {
if (document.fullscreenElement && document.exitFullscreen) {
await document.exitFullscreen()
return
}
} catch {
/* try webkit */
}
try {
if (doc.webkitExitFullscreen) {
doc.webkitExitFullscreen()
}
} catch {
/* ignore */
}
}
export default function USOutbreakMap({ reports = [], onReportSubmit, reporterVerified }: USOutbreakMapProps) {
const [selectedLocation, setSelectedLocation] = useState<{ lat: number; lng: number } | null>(null)
const [showReportForm, setShowReportForm] = useState(false)
const mapCardRef = useRef<HTMLDivElement>(null)
const mapInstanceRef = useRef<google.maps.Map | null>(null)
const [browserFullscreen, setBrowserFullscreen] = useState(false)
/** iOS / browsers without element fullscreen */
const [layoutFullscreen, setLayoutFullscreen] = useState(false)
const expanded = browserFullscreen || layoutFullscreen
const handleMapReady = useCallback((map: google.maps.Map) => {
mapInstanceRef.current = map
}, [])
useEffect(() => {
const syncFs = () => {
const fsEl = getFullscreenElement()
setBrowserFullscreen(fsEl === mapCardRef.current)
}
syncFs()
document.addEventListener('fullscreenchange', syncFs)
document.addEventListener('webkitfullscreenchange', syncFs)
return () => {
document.removeEventListener('fullscreenchange', syncFs)
document.removeEventListener('webkitfullscreenchange', syncFs)
}
}, [])
useEffect(() => {
triggerMapResize(mapInstanceRef.current)
}, [expanded])
useEffect(() => {
const onResize = () => triggerMapResize(mapInstanceRef.current)
window.addEventListener('orientationchange', onResize)
window.addEventListener('resize', onResize)
return () => {
window.removeEventListener('orientationchange', onResize)
window.removeEventListener('resize', onResize)
}
}, [])
const exitAllFullscreen = useCallback(async () => {
if (getFullscreenElement()) {
await exitDocumentFullscreen()
}
setLayoutFullscreen(false)
}, [])
const toggleFullscreen = useCallback(async () => {
const el = mapCardRef.current
if (!el) return
if (expanded) {
await exitAllFullscreen()
triggerMapResize(mapInstanceRef.current)
return
}
const enteredFs = await requestElementFullscreen(el)
if (enteredFs) {
triggerMapResize(mapInstanceRef.current)
return
}
setLayoutFullscreen(true)
triggerMapResize(mapInstanceRef.current)
}, [expanded, exitAllFullscreen])
useEffect(() => {
if (!layoutFullscreen && !browserFullscreen) return
const prev = document.body.style.overflow
document.body.style.overflow = 'hidden'
return () => {
document.body.style.overflow = prev
}
}, [layoutFullscreen, browserFullscreen])
useEffect(() => {
if (!expanded) return
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') void exitAllFullscreen()
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [expanded, exitAllFullscreen])
useEffect(() => {
return () => {
document.body.style.removeProperty('overflow')
void exitDocumentFullscreen()
}
}, [])
// Ensure modal closes on mount/unmount to prevent stuck overlays
useEffect(() => {
return () => {
setShowReportForm(false)
setSelectedLocation(null)
}
}, [])
const [formData, setFormData] = useState({
crop: '',
disease: '',
severity: 'medium' as 'low' | 'medium' | 'high',
description: '',
})
const handleMapClick = (lat: number, lng: number) => {
// Restrict to US bounds
const clampedLat = Math.max(24.39, Math.min(49.38, lat))
const clampedLng = Math.max(-125, Math.min(-66.93, lng))
setSelectedLocation({ lat: clampedLat, lng: clampedLng })
setShowReportForm(true)
}
const handleSubmitReport = () => {
if (!selectedLocation || !formData.crop || !formData.disease) {
alert('Please fill in all required fields')
return
}
const newReport: OutbreakReport = {
id: Date.now().toString(),
lat: selectedLocation.lat,
lng: selectedLocation.lng,
crop: formData.crop,
disease: formData.disease,
severity: formData.severity,
date: new Date().toISOString(),
description: formData.description,
reporterVerified,
}
if (onReportSubmit) {
onReportSubmit(newReport)
}
setShowReportForm(false)
setSelectedLocation(null)
setFormData({
crop: '',
disease: '',
severity: 'medium',
description: '',
})
}
return (
<div className="relative w-full">
{/* Map card: explicit height so the map fills the area (no aspect-ratio gap) */}
<div
ref={mapCardRef}
className={`flex w-full flex-col overflow-hidden rounded-xl border border-field-soil/10 bg-white shadow-sm ${
layoutFullscreen
? 'fixed inset-0 z-[5000] h-[100dvh] max-h-[100dvh] min-h-0 w-full max-w-none rounded-none border-slate-300 pt-[env(safe-area-inset-top)] pb-[env(safe-area-inset-bottom)]'
: ''
} ${expanded ? 'h-full min-h-0' : ''}`}
>
<div
className={`relative flex w-full min-h-0 flex-col ${
expanded ? 'h-full min-h-0 flex-1' : ''
}`}
>
<div
className={`relative min-h-0 w-full overflow-hidden ${
expanded
? 'min-h-0 flex-1'
: 'h-[min(52dvh,560px)] min-h-[280px] sm:min-h-[420px]'
}`}
>
<GoogleMapComponent
reports={reports}
onMapClick={handleMapClick}
center={{ lat: 39.8283, lng: -98.5795 }}
zoom={4}
showMapClickHint={false}
fullscreenControl
onMapReady={handleMapReady}
/>
<div className="pointer-events-auto absolute right-2 top-2 z-[1001] flex gap-1 sm:right-3 sm:top-3">
<button
type="button"
onClick={() => void toggleFullscreen()}
className="touch-manipulation rounded-lg border border-field-soil/15 bg-white/95 px-2.5 py-2 text-primary-900 shadow-md backdrop-blur-sm transition hover:bg-field-cream sm:px-3"
aria-label={expanded ? 'Exit fullscreen map' : 'Fullscreen map'}
title={expanded ? 'Exit fullscreen' : 'Fullscreen'}
>
{expanded ? (
<Minimize2 className="h-5 w-5 sm:h-5 sm:w-5" />
) : (
<Maximize2 className="h-5 w-5 sm:h-5 sm:w-5" />
)}
</button>
</div>
{selectedLocation && !showReportForm && (
<div className="pointer-events-none absolute left-2 top-12 z-[1000] max-w-[calc(100%-4rem)] rounded-lg border border-primary-200 bg-white p-2 shadow-xl sm:left-3 sm:top-14 sm:p-3">
<p className="mb-0.5 text-[11px] font-bold text-primary-900 sm:text-xs">Selected location</p>
<p className="break-all font-mono text-[10px] text-slate-600 sm:text-xs">
{selectedLocation.lat.toFixed(4)}, {selectedLocation.lng.toFixed(4)}
</p>
</div>
)}
</div>
<div className="flex shrink-0 items-center gap-2 border-t border-field-soil/10 bg-field-cream px-3 py-2.5 sm:px-4 sm:py-3">
<MapPin className="h-4 w-4 shrink-0 text-primary-700" />
<p className="text-xs font-semibold leading-snug text-primary-900 sm:text-sm">
Tap the map where you are seeing crop trouble
</p>
</div>
</div>
</div>
{/* Report Form Modal — no AnimatePresence exit on full-screen layer (opacity-0 still captures clicks). */}
{showReportForm && (
<div className="fixed inset-0 z-[6000] flex items-end justify-center sm:items-center sm:p-4">
<div
role="presentation"
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={() => {
setShowReportForm(false)
setSelectedLocation(null)
}}
/>
<div
onClick={(e) => e.stopPropagation()}
className="relative z-10 max-h-[90dvh] w-full max-w-md overflow-y-auto overscroll-contain rounded-t-2xl border border-field-soil/10 bg-white p-5 pb-[max(1.25rem,env(safe-area-inset-bottom))] shadow-2xl sm:rounded-2xl sm:p-6 sm:pb-6"
>
<div className="flex items-center justify-between mb-6">
<h2 className="flex items-center gap-3 text-2xl font-bold text-primary-900">
<div className="rounded-lg bg-field-wheat/40 p-2">
<AlertTriangle className="h-6 w-6 text-field-soil" />
</div>
Report crop trouble
</h2>
<button
onClick={() => {
setShowReportForm(false)
setSelectedLocation(null)
}}
className="rounded-lg p-1 text-field-soil transition-colors hover:bg-field-cream hover:text-primary-900"
>
<X className="w-5 h-5" />
</button>
</div>
{selectedLocation && (
<div className="mb-4 rounded-lg border border-primary-100 bg-primary-50 p-3">
<p className="mb-1 text-xs font-semibold text-primary-800">Location</p>
<p className="font-mono text-sm text-primary-900">
{selectedLocation.lat.toFixed(4)}, {selectedLocation.lng.toFixed(4)}
</p>
</div>
)}
<div className="space-y-4">
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">
Crop affected <span className="text-red-500">*</span>
</label>
<input
type="text"
value={formData.crop}
onChange={(e) => setFormData({ ...formData, crop: e.target.value })}
placeholder="Corn, wheat, rice"
className="field-input py-2.5"
/>
</div>
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">
What it looks like <span className="text-red-500">*</span>
</label>
<input
type="text"
value={formData.disease}
onChange={(e) => setFormData({ ...formData, disease: e.target.value })}
placeholder="Rust, blight, yellowing"
className="field-input py-2.5"
/>
</div>
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">
How bad is it? <span className="text-red-500">*</span>
</label>
<select
value={formData.severity}
onChange={(e) =>
setFormData({
...formData,
severity: e.target.value as 'low' | 'medium' | 'high',
})
}
className="field-input py-2.5"
>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
</div>
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">
Field notes
</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="How much of the field is affected? When did you notice it?"
rows={3}
className="field-input resize-none py-2.5"
/>
</div>
<button
type="button"
onClick={handleSubmitReport}
className="btn-primary w-full"
>
<Save className="w-5 h-5" />
Share report
</button>
</div>
</div>
</div>
)}
</div>
)
}