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 \n \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 (

Watering - Sprinklers

Plan Points
    {sortedPoints.map((pt, idx) => (
  • onSelectPoint(pt)}> #{idx+1} {Number(pt.lat).toFixed(5)}, {Number(pt.lng).toFixed(5)} {pt.equipment_name || (pt.sprinkler_head_type||'-').replace('_',' ')} {pt.sprinkler_throw_feet ? ` • ${Number(pt.sprinkler_throw_feet)}ft` : ''} {pt.sprinkler_degrees ? ` • ${pt.sprinkler_degrees}°` : ''}
  • ))} {points.length===0 &&
  • No points yet
  • }
{selectedPoint && editForm && (
Selected Details
{selectedPoint?.equipment_name ? (<> {selectedPoint.equipment_name} • ) : null} Type: {editForm.sprinklerHeadType || '-'} • GPM: {editForm.gpm || '-'} • Duration: {editForm.durationMinutes||0}m
{(editForm.sprinklerHeadType==='rotor_impact' || editForm.sprinklerHeadType==='spray_fixed') ? (
Throw: {editForm.throwFeet||0} ft • Degrees: {editForm.degrees||0}° • Heading: {editForm.headingDegrees||0}°
) : (
Length: {editForm.lengthFeet||0} ft • Width: {editForm.widthFeet||0} ft • Heading: {editForm.headingDegrees||0}°
)}
)}
Plan
{plan && ( )} {plan && ( )}
{plan && (
setRename(e.target.value)} />
)}
Sprinkler Settings
Choose Saved Sprinkler
{(!selectedSprinklerId) && ( { // refresh list const eq = await equipmentAPI.getAll(); const list = (eq.data?.data?.equipment||[]).filter(e => (e.categoryName||'').toLowerCase()==='sprinkler'); setSprinklers(list); setSelectedSprinklerId(String(newEq.id)); }} /> )}
Mount
Type
{sprinklerForm.type === 'rotor_impact' || sprinklerForm.type === 'spray_fixed' ? ( <>
GPM
setSprinklerForm({...sprinklerForm, gpm: parseFloat(e.target.value)})} />
Throw distance (ft)
setSprinklerForm({...sprinklerForm, throwFeet: parseFloat(e.target.value)})} />
Degrees (0‑360)
setSprinklerForm({...sprinklerForm, degrees: parseInt(e.target.value||'0',10)})} />
Heading (0‑359, 0 = East)
setSprinklerForm({...sprinklerForm, headingDegrees: (parseInt(e.target.value||'0',10)||0)%360})} /> ) : ( <>
Length (ft)
setSprinklerForm({...sprinklerForm, lengthFeet: parseFloat(e.target.value)})} />
Width (ft)
setSprinklerForm({...sprinklerForm, widthFeet: parseFloat(e.target.value)})} /> )}
Target Depth (inches)
setTargetInches(parseFloat(e.target.value||'0'))} />
Suggested runtime: {suggestMinutes} minutes {sprinklerForm.gpm? '' : '(enter GPM to calculate)'}
Run Duration (minutes)
setSprinklerForm({...sprinklerForm, durationMinutes: parseInt(e.target.value||'0',10)})} />
{selectedPoint && editForm && (
Edit Selected
{selectedPoint?.equipment_id && ( )}
{(editForm.sprinklerHeadType==='rotor_impact' || editForm.sprinklerHeadType==='spray_fixed') ? ( <> setEditForm({...editForm, degrees: parseInt(e.target.value||'0',10)})} /> setEditForm({...editForm, headingDegrees: parseInt(e.target.value||'0',10)%360})} /> setEditForm({...editForm, throwFeet: parseFloat(e.target.value||'0')})} /> ) : ( <> setEditForm({...editForm, lengthFeet: parseFloat(e.target.value||'0')})} /> setEditForm({...editForm, widthFeet: parseFloat(e.target.value||'0')})} /> setEditForm({...editForm, headingDegrees: parseInt(e.target.value||'0',10)%360})} /> )} setEditForm({...editForm, durationMinutes: parseInt(e.target.value||'0',10)})} />
)} {!selectedPoint &&
Select a point to edit
}
{guiding && currentPos && sortedPoints[guideIndex] ? (()=>{ const tgt = {lat:Number(sortedPoints[guideIndex].lat), lng:Number(sortedPoints[guideIndex].lng)}; const dist = distanceFeet(currentPos, tgt); const brg = bearingDegrees(currentPos, tgt); if (dist <= 15) { return Arrived at #{guideIndex+1} • {dist.toFixed(0)} ft; } return <>→ {brg}° • {dist.toFixed(0)} ft • Point #{guideIndex+1}; })() : 'Map'}
{!guiding ? ( ) : ( <> )}
{satellite ? ( ) : ( )} {sections.map((s)=> ( ))} {placing && } {guiding && currentPos && sortedPoints[guideIndex] && ( <> {/* Current location marker */} {/* Line to target */} {/* Arrival ring */} )} {/* Legend overlay */} {sortedPoints.length > 0 && (
Legend
    {sortedPoints.map((pt, idx) => (
  • #{idx+1} {pt.equipment_name || (pt.sprinkler_head_type||'-').replace('_',' ')} {pt.sprinkler_gpm ? (• {Number(pt.sprinkler_gpm)} gpm) : null} {pt.duration_minutes ? (• {pt.duration_minutes} min) : null}
  • ))}
)} {sortedPoints.map(pt => { const cov = computeCoverage({ type: pt.sprinkler_head_type, throwFeet: parseFloat(pt.sprinkler_throw_feet||0), degrees: parseInt(pt.sprinkler_degrees||360,10), lengthFeet: parseFloat(pt.sprinkler_length_feet||0), widthFeet: parseFloat(pt.sprinkler_width_feet||0) }); const color = pointColor(pt); return ( { const ll = e.target.getLatLng(); updatePointField(pt.id, { lat: ll.lat, lng: ll.lng }); }, click: ()=> onSelectPoint(pt) }} >
{pt.equipment_name || 'Adjust Sprinkler'}
{(pt.sprinkler_head_type==='rotor_impact' || pt.sprinkler_head_type==='spray_fixed') && ( <>
Degrees: {Number(pt.sprinkler_degrees||0)}°
Throw: {Number(pt.sprinkler_throw_feet||0)} ft
)} {pt.sprinkler_head_type==='oscillating_fan' && ( <>
Length: {Number(pt.sprinkler_length_feet||0)} ft
Width: {Number(pt.sprinkler_width_feet||0)} ft
)}
Heading: {Number(pt.sprinkler_heading_degrees||0)}°
Move 1 ft
{cov?.kind==='circle' && ( cov.degrees && cov.degrees < 360 ? ( ) : ( ) )} {cov?.kind==='rect' && (()=>{ const clat=Number(pt.lat), clng=Number(pt.lng); const L=cov.length, W=cov.width; const hdg=(Number(pt.sprinkler_heading_degrees||0))*Math.PI/180; const Rlat=111320, Rlng=Math.cos(clat*Math.PI/180)*111320; const hf=L/2*0.3048, wf=W/2*0.3048; const corners=[{x:hf,y:wf},{x:-hf,y:wf},{x:-hf,y:-wf},{x:hf,y:-wf}].map(({x,y})=>{ const xr=x*Math.cos(hdg)-y*Math.sin(hdg); const yr=x*Math.sin(hdg)+y*Math.cos(hdg); const lat=clat + (yr/Rlat); const lng=clng + (xr/Rlng); return [lat,lng]; }); const color = pointColor(pt); return ; })()}
); })}
); }; export default Watering; // Inline component to save current sprinkler settings as user equipment const SaveSprinklerInline = ({ current, onSaved }) => { const [name, setName] = useState(''); const [saving, setSaving] = useState(false); const save = async () => { if (!name.trim()) { toast.error('Enter a name'); return; } setSaving(true); try { // Find Sprinkler category id const cats = await equipmentAPI.getCategories(); const list = cats.data?.data?.categories || []; const spr = list.find(c => (c.name||'').toLowerCase() === 'sprinkler'); if (!spr) { toast.error('Sprinkler category not found'); setSaving(false); return; } const payload = { categoryId: spr.id, customName: name.trim(), // store sprinkler-specific fields sprinklerMount: current.mount, sprinklerHeadType: current.type, sprinklerGpm: current.gpm, sprinklerThrowFeet: current.throwFeet, sprinklerDegrees: current.degrees, sprinklerLengthFeet: current.lengthFeet, sprinklerWidthFeet: current.widthFeet, notes: 'Saved from Watering settings', }; const rs = await equipmentAPI.create(payload); const eq = rs.data?.data?.equipment; toast.success('Sprinkler saved'); onSaved && onSaved(eq); setName(''); } catch(e){ toast.error('Failed to save sprinkler'); } finally { setSaving(false); } }; return (
Save current settings as equipment
setName(e.target.value)} />
); };