import React, { useEffect, useMemo, useState } from 'react'; import { MapContainer, TileLayer, Polygon, Marker, Circle, Rectangle, Popup, useMapEvents, useMap, Polyline } from 'react-leaflet'; import { Icon } from 'leaflet'; import 'leaflet/dist/leaflet.css'; import { propertiesAPI, wateringAPI, equipmentAPI } from '../../services/api'; import toast from 'react-hot-toast'; const SprinklerPlacement = ({ onPlace }) => { useMapEvents({ click(e) { onPlace([e.latlng.lat, e.latlng.lng]); } }); return null; }; const computeCoverage = (sprinkler) => { if (sprinkler.type === 'rotor_impact' || sprinkler.type === 'spray_fixed') { return { kind: 'circle', radius: sprinkler.throwFeet, degrees: sprinkler.degrees || 360 }; } if (sprinkler.type === 'oscillating_fan') { const L = sprinkler.lengthFeet || 0; const W = sprinkler.widthFeet || 0; return { kind: 'rect', length: L, width: W }; } return null; }; const Watering = () => { const [properties, setProperties] = useState([]); const [selectedProperty, setSelectedProperty] = useState(null); const [sections, setSections] = useState([]); const [placing, setPlacing] = useState(false); const [sprinklerForm, setSprinklerForm] = useState({ mount: 'above_ground', type: 'rotor_impact', gpm: 2.5, throwFeet: 20, degrees: 360, lengthFeet: 30, widthFeet: 20, durationMinutes: 60 }); const [plan, setPlan] = useState(null); const [plans, setPlans] = useState([]); const [points, setPoints] = useState([]); const [guiding, setGuiding] = useState(false); const [guideIndex, setGuideIndex] = useState(0); const [currentPos, setCurrentPos] = useState(null); const [watchId, setWatchId] = useState(null); const [sprinklers, setSprinklers] = useState([]); const [selectedSprinklerId, setSelectedSprinklerId] = useState(''); const [selectedPointId, setSelectedPointId] = useState(null); const selectedPoint = useMemo(()=> points.find(p=> p.id===selectedPointId), [points, selectedPointId]); const sortedPoints = useMemo(() => { return [...points].sort((a,b) => (Number(a.sequence||0)) - (Number(b.sequence||0)) || (Number(a.id)-Number(b.id))); }, [points]); const [editForm, setEditForm] = useState(null); const [satellite, setSatellite] = useState(false); const [rename, setRename] = useState(''); // Distinct colors for points const palette = ['#ef4444','#f59e0b','#10b981','#3b82f6','#8b5cf6','#ec4899','#14b8a6','#84cc16','#f97316','#06b6d4','#a855f7','#22c55e']; const pointColor = (pt) => { const idx = points.findIndex(p=> p.id===pt.id); return palette[(idx>=0? idx:0) % palette.length]; }; const iconCache = useMemo(()=> ({}), []); const markerIcon = (color) => { if (iconCache[color]) return iconCache[color]; const svg = `\n `; const ic = new Icon({ iconUrl: 'data:image/svg+xml;base64,' + btoa(svg), iconSize: [16,16], iconAnchor: [8,8] }); iconCache[color] = ic; return ic; }; // Select a point helper (for popup+state sync) const onSelectPoint = (pt) => { setSelectedPointId(pt.id); setEditForm({ durationMinutes: pt.duration_minutes || 0, mountType: pt.sprinkler_mount, sprinklerHeadType: pt.sprinkler_head_type, gpm: Number(pt.sprinkler_gpm||0), throwFeet: Number(pt.sprinkler_throw_feet||0), degrees: Number(pt.sprinkler_degrees||360), lengthFeet: Number(pt.sprinkler_length_feet||0), widthFeet: Number(pt.sprinkler_width_feet||0), headingDegrees: Number(pt.sprinkler_heading_degrees||0) }); }; // Optimistic local patch + save to server const applyLocalPatch = (id, patch) => { setPoints(prev => prev.map(p => p.id === id ? { ...p, sprinkler_degrees: patch.degrees !== undefined ? patch.degrees : p.sprinkler_degrees, sprinkler_heading_degrees: patch.headingDegrees !== undefined ? patch.headingDegrees : p.sprinkler_heading_degrees, sprinkler_throw_feet: patch.throwFeet !== undefined ? patch.throwFeet : p.sprinkler_throw_feet, sprinkler_length_feet: patch.lengthFeet !== undefined ? patch.lengthFeet : p.sprinkler_length_feet, sprinkler_width_feet: patch.widthFeet !== undefined ? patch.widthFeet : p.sprinkler_width_feet, duration_minutes: patch.durationMinutes !== undefined ? patch.durationMinutes : p.duration_minutes, lat: patch.lat !== undefined ? patch.lat : p.lat, lng: patch.lng !== undefined ? patch.lng : p.lng } : p)); }; const updatePointField = async (id, patch) => { try { applyLocalPatch(id, patch); if (id === selectedPointId) { setEditForm(prev => prev ? { ...prev, degrees: patch.degrees !== undefined ? patch.degrees : prev.degrees, headingDegrees: patch.headingDegrees !== undefined ? patch.headingDegrees : prev.headingDegrees, throwFeet: patch.throwFeet !== undefined ? patch.throwFeet : prev.throwFeet, lengthFeet: patch.lengthFeet !== undefined ? patch.lengthFeet : prev.lengthFeet, widthFeet: patch.widthFeet !== undefined ? patch.widthFeet : prev.widthFeet, durationMinutes: patch.durationMinutes !== undefined ? patch.durationMinutes : prev.durationMinutes, } : prev); } const r = await wateringAPI.updatePoint(id, patch); const np = r.data?.data?.point; setPoints(prev => prev.map(p=> p.id===id? np: p)); } catch(e){ toast.error('Failed to update point'); } }; const nudgePointLocation = (pt, dxFeet, dyFeet) => { const lat = Number(pt.lat), lng = Number(pt.lng); const Rlat = 111320; // meters per degree lat const Rlng = Math.cos(lat*Math.PI/180)*111320; // meters per degree lng const dxm = dxFeet*0.3048; // east-west const dym = dyFeet*0.3048; // north-south const newLat = lat + (dym / Rlat); const newLng = lng + (dxm / Rlng); updatePointField(pt.id, { lat: newLat, lng: newLng }); }; const adjustHeading = (pt, delta) => { const cur = Number(pt.sprinkler_heading_degrees || 0); let next = (cur + delta) % 360; if (next < 0) next += 360; updatePointField(pt.id, { headingDegrees: next }); }; useEffect(() => { (async () => { try { const [pr, eq] = await Promise.all([propertiesAPI.getAll(), equipmentAPI.getAll()]); setProperties(pr.data?.data?.properties||[]); const list = (eq.data?.data?.equipment||[]).filter(e => (e.categoryName||'').toLowerCase()==='sprinkler'); setSprinklers(list); } catch(e){ toast.error('Failed to load properties'); } })(); }, []); const loadProperty = async (pid) => { try { const r = await propertiesAPI.getById(pid); const p = r.data?.data?.property; setSelectedProperty(p); const secs = (p.sections||[]).map(s => ({ ...s, polygonData: typeof s.polygonData === 'string' ? JSON.parse(s.polygonData) : s.polygonData })); setSections(secs); // Load existing watering plans for this property const pl = await wateringAPI.getPlans({ property_id: pid }); const plansList = pl.data?.data?.plans || []; setPlans(plansList); if (plansList.length) { const sel = plansList[0]; setPlan(sel); const pts = await wateringAPI.getPlanPoints(sel.id); setPoints(pts.data?.data?.points || []); } else { setPlan(null); setPoints([]); } } catch(e){ toast.error('Failed to load property'); } }; const ensurePlan = async () => { if (plan) return plan; if (!selectedProperty) { toast.error('Select a property first'); return null; } try { const r = await wateringAPI.createPlan({ propertyId: selectedProperty.id, name: `Sprinklers` }); const newPlan = r.data?.data?.plan; setPlan(newPlan); // refresh list and clear points const pl = await wateringAPI.getPlans({ property_id: selectedProperty.id }); setPlans(pl.data?.data?.plans || []); setPoints([]); return newPlan; } catch(e){ toast.error('Failed to create plan'); return null; } }; const selectPlan = async (planId) => { const p = (plans||[]).find(pl=> pl.id === parseInt(planId)); setPlan(p || null); if (p) { try { const rs = await wateringAPI.getPlanPoints(p.id); setPoints(rs.data?.data?.points || []); } catch(e){ toast.error('Failed to load plan points'); setPoints([]); } } else { setPoints([]); } }; const onPlace = async (latlng) => { const p = await ensurePlan(); if (!p) return; try { const eq = sprinklers.find(s=> s.id === (selectedSprinklerId? parseInt(selectedSprinklerId): -1)); const base = eq ? { mount: eq.sprinklerMount || sprinklerForm.mount, type: eq.sprinklerHeadType || sprinklerForm.type, gpm: eq.sprinklerGpm || sprinklerForm.gpm, throwFeet: eq.sprinklerThrowFeet || sprinklerForm.throwFeet, degrees: (eq.sprinklerDegrees !== undefined && eq.sprinklerDegrees !== null) ? eq.sprinklerDegrees : sprinklerForm.degrees, lengthFeet: eq.sprinklerLengthFeet || sprinklerForm.lengthFeet, widthFeet: eq.sprinklerWidthFeet || sprinklerForm.widthFeet } : sprinklerForm; const payload = { lat: latlng[0], lng: latlng[1], durationMinutes: sprinklerForm.durationMinutes, mountType: base.mount, sprinklerHeadType: base.type, gpm: base.gpm, throwFeet: base.throwFeet, degrees: base.degrees, lengthFeet: base.lengthFeet, widthFeet: base.widthFeet, headingDegrees: sprinklerForm.headingDegrees || 0, equipmentId: eq ? eq.id : null, equipmentName: eq ? (eq.customName || `${eq.manufacturer||''} ${eq.model||''}`.trim()) : null }; const r = await wateringAPI.addPlanPoint(p.id, payload); setPoints(prev => [...prev, r.data?.data?.point]); toast.success('Sprinkler location added'); setPlacing(false); } catch(e){ toast.error('Failed to add sprinkler point'); } }; const center = useMemo(() => { if (selectedProperty?.latitude && selectedProperty?.longitude) return [Number(selectedProperty.latitude), Number(selectedProperty.longitude)]; if (sections?.length){ const pts = sections.flatMap(s => (s.polygonData?.coordinates?.[0]||[])); if (pts.length){ const lat = pts.reduce((a,p)=> a + Number(p[0]), 0) / pts.length; const lng = pts.reduce((a,p)=> a + Number(p[1]), 0) / pts.length; return [lat, lng]; } } return [39.8,-98.6]; }, [selectedProperty, sections]); const CenterOnChange = ({ center }) => { const map = useMap(); useEffect(() => { if (center && Array.isArray(center)) map.setView(center); }, [center, map]); return null; }; useEffect(() => { setRename(plan?.name || ''); }, [plan]); const startGuidance = () => { if (points.length === 0) { toast.error('Add at least one point'); return; } if (!navigator.geolocation) { toast.error('GPS not available'); return; } setGuiding(true); setGuideIndex(0); const id = navigator.geolocation.watchPosition(pos => { setCurrentPos({ lat: pos.coords.latitude, lng: pos.coords.longitude }); }, err => { console.warn(err); toast.error('GPS error'); }, { enableHighAccuracy:true, maximumAge: 1000, timeout: 10000 }); setWatchId(id); }; const stopGuidance = () => { if (watchId){ navigator.geolocation.clearWatch(watchId); setWatchId(null);} setGuiding(false); }; const distanceFeet = (a,b) => { const R=6371000; const toRad=d=>d*Math.PI/180; const dLat=toRad(b.lat-a.lat); const dLng=toRad(b.lng-a.lng); const A=Math.sin(dLat/2)**2 + Math.cos(toRad(a.lat))*Math.cos(toRad(b.lat))*Math.sin(dLng/2)**2; return (2*R*Math.atan2(Math.sqrt(A),Math.sqrt(1-A)))*3.28084; }; const bearingDegrees = (a,b) => { const toRad = d=> d*Math.PI/180, toDeg = r=> (r*180/Math.PI+360)%360; const lat1 = toRad(a.lat), lat2 = toRad(b.lat); const dLng = toRad(b.lng - a.lng); const y = Math.sin(dLng) * Math.cos(lat2); const x = Math.cos(lat1)*Math.sin(lat2) - Math.sin(lat1)*Math.cos(lat2)*Math.cos(dLng); return Math.round(toDeg(Math.atan2(y,x))); }; // Build sector polygon (approx) for Leaflet const sectorPolygon = (center, radiusFeet, startDeg, endDeg, steps=60) => { const [clat, clng] = [Number(center.lat), Number(center.lng)]; const Rlat = 111320; // meters per degree lat const Rlng = Math.cos(clat*Math.PI/180)*111320; // meters per degree lng const rm = radiusFeet*0.3048; // meters const pts = []; const start = startDeg*Math.PI/180; const end = endDeg*Math.PI/180; const span = end - start; const n = Math.max(8, Math.round(steps*Math.abs(span)/(2*Math.PI))); pts.push([clat, clng]); for (let i=0;i<=n;i++){ const ang = start + (span*i/n); const dx = rm * Math.cos(ang); const dy = rm * Math.sin(ang); const lat = clat + (dy / Rlat); const lng = clng + (dx / Rlng); pts.push([lat, lng]); } pts.push([clat, clng]); return pts; }; const coverageSqft = useMemo(() => { const type = sprinklerForm.type; if (type==='rotor_impact' || type==='spray_fixed') { const r = Number(sprinklerForm.throwFeet||0); const deg=Number(sprinklerForm.degrees||360); return Math.PI * r * r * (deg/360); } if (type==='oscillating_fan') { return Number(sprinklerForm.lengthFeet||0) * Number(sprinklerForm.widthFeet||0); } return 0; }, [sprinklerForm]); const [targetInches, setTargetInches] = useState(0.5); const suggestMinutes = useMemo(() => { const gpm = Number(sprinklerForm.gpm||0); if (!gpm || !coverageSqft || !targetInches) return 0; const gallonsNeeded = coverageSqft * targetInches * 0.623; // gal per sqft-inch return Math.ceil((gallonsNeeded / gpm)); }, [sprinklerForm.gpm, coverageSqft, targetInches]); return (