Files
turftracker/frontend/src/pages/Watering/Watering.js
Jake Kasper ff029fe073 asdf
2025-09-05 08:33:28 -04:00

424 lines
22 KiB
JavaScript
Raw 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, { useEffect, useMemo, useState } from 'react';
import { MapContainer, TileLayer, Polygon, Marker, Circle, Rectangle, useMapEvents, useMap } from 'react-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 [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 [editForm, setEditForm] = useState(null);
const [satellite, setSatellite] = useState(false);
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);
}
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: `${selectedProperty.name} - Sprinklers` }); setPlan(r.data?.data?.plan); return r.data?.data?.plan; }
catch(e){ toast.error('Failed to create plan'); return null; }
};
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: 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
};
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;
};
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;
};
// 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]);
// Select a point to edit (enables popup edits too)
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)
});
};
// Persist changes to a point and refresh it in local state
const updatePointField = async (id, patch) => {
try {
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'); }
};
return (
<div className="p-6">
<h1 className="text-2xl font-bold mb-4">Watering - Sprinklers</h1>
<div className="grid grid-cols-1 lg:grid-cols-4 gap-4">
<div className="lg:col-span-1 space-y-4">
<div className="card">
<label className="block text-sm font-medium mb-1">Property</label>
<select className="input" value={selectedProperty?.id||''} onChange={(e)=> loadProperty(parseInt(e.target.value))}>
<option value="">Select property</option>
{properties.map(p=> (<option key={p.id} value={p.id}>{p.name}</option>))}
</select>
</div>
<div className="card space-y-2">
<div className="font-medium">Sprinkler Settings</div>
<div className="text-sm">Choose Saved Sprinkler</div>
<select className="input" value={selectedSprinklerId} onChange={(e)=> {
const val=e.target.value; setSelectedSprinklerId(val);
const eq = sprinklers.find(s=> s.id === parseInt(val));
if (eq) {
setSprinklerForm(sf=> ({...sf,
mount: eq.sprinklerMount || sf.mount,
type: eq.sprinklerHeadType || sf.type,
gpm: eq.sprinklerGpm || sf.gpm,
throwFeet: eq.sprinklerThrowFeet || sf.throwFeet,
lengthFeet: eq.sprinklerLengthFeet || sf.lengthFeet,
widthFeet: eq.sprinklerWidthFeet || sf.widthFeet
}));
}
}}>
<option value="">-- none --</option>
{sprinklers.map(s=> (
<option key={s.id} value={s.id}>{s.customName || `${s.manufacturer||''} ${s.model||''}`.trim()}</option>
))}
</select>
<div className="text-sm">Mount</div>
<select className="input" value={sprinklerForm.mount} onChange={e=> setSprinklerForm({...sprinklerForm, mount:e.target.value})}>
<option value="in_ground">InGround</option>
<option value="above_ground">AboveGround</option>
</select>
<div className="text-sm">Type</div>
<select className="input" value={sprinklerForm.type} onChange={e=> setSprinklerForm({...sprinklerForm, type:e.target.value})}>
<option value="rotor_impact">Rotor/Impact</option>
<option value="oscillating_fan">Oscillating/Fan</option>
<option value="spray_fixed">Spray (Fixed)</option>
</select>
{sprinklerForm.type === 'rotor_impact' || sprinklerForm.type === 'spray_fixed' ? (
<>
<div className="text-sm">GPM</div>
<input type="number" step="0.1" className="input" value={sprinklerForm.gpm} onChange={e=> setSprinklerForm({...sprinklerForm, gpm: parseFloat(e.target.value)})} />
<div className="text-sm">Throw distance (ft)</div>
<input type="number" step="0.1" className="input" value={sprinklerForm.throwFeet} onChange={e=> setSprinklerForm({...sprinklerForm, throwFeet: parseFloat(e.target.value)})} />
<div className="text-sm">Degrees (0360)</div>
<input type="number" className="input" value={sprinklerForm.degrees} onChange={e=> setSprinklerForm({...sprinklerForm, degrees: parseInt(e.target.value||'0',10)})} />
<div className="text-sm">Heading (0359, 0 = East)</div>
<input type="number" className="input" value={sprinklerForm.headingDegrees||0} onChange={e=> setSprinklerForm({...sprinklerForm, headingDegrees: (parseInt(e.target.value||'0',10)||0)%360})} />
</>
) : (
<>
<div className="text-sm">Length (ft)</div>
<input type="number" step="0.1" className="input" value={sprinklerForm.lengthFeet} onChange={e=> setSprinklerForm({...sprinklerForm, lengthFeet: parseFloat(e.target.value)})} />
<div className="text-sm">Width (ft)</div>
<input type="number" step="0.1" className="input" value={sprinklerForm.widthFeet} onChange={e=> setSprinklerForm({...sprinklerForm, widthFeet: parseFloat(e.target.value)})} />
</>
)}
<div className="text-sm">Target Depth (inches)</div>
<input type="number" step="0.1" className="input" value={targetInches} onChange={e=> setTargetInches(parseFloat(e.target.value||'0'))} />
<div className="text-xs text-gray-600">Suggested runtime: {suggestMinutes} minutes {sprinklerForm.gpm? '' : '(enter GPM to calculate)'}
</div>
<div className="text-sm">Run Duration (minutes)</div>
<input type="number" className="input" value={sprinklerForm.durationMinutes} onChange={e=> setSprinklerForm({...sprinklerForm, durationMinutes: parseInt(e.target.value||'0',10)})} />
<button className="btn-primary w-full" disabled={!selectedProperty} onClick={()=> setPlacing(true)}>Place Sprinkler on Map</button>
</div>
<div className="card">
<div className="font-medium mb-2">Plan Points</div>
<ul className="text-sm space-y-1 max-h-64 overflow-auto">
{points.map(pt => (
<li key={pt.id}
className={(pt.id===selectedPointId? 'bg-blue-50 ' : '') + 'rounded px-2 py-1 hover:bg-gray-50 cursor-pointer'}
onClick={()=> onSelectPoint(pt)}>
#{pt.sequence}: {Number(pt.lat).toFixed(5)}, {Number(pt.lng).toFixed(5)} {pt.duration_minutes} min
</li>
))}
{points.length===0 && <li className="text-gray-500">No points yet</li>}
</ul>
{selectedPoint && editForm && (
<div className="mt-3 border-t pt-3 space-y-2">
<div className="font-medium">Edit Selected</div>
{(editForm.sprinklerHeadType==='rotor_impact' || editForm.sprinklerHeadType==='spray_fixed') ? (
<>
<label className="text-xs">Degrees</label>
<input type="number" className="input" value={editForm.degrees}
onChange={(e)=> setEditForm({...editForm, degrees: parseInt(e.target.value||'0',10)})} />
<label className="text-xs">Heading</label>
<input type="number" className="input" value={editForm.headingDegrees}
onChange={(e)=> setEditForm({...editForm, headingDegrees: parseInt(e.target.value||'0',10)%360})} />
<label className="text-xs">Throw (ft)</label>
<input type="number" step="0.1" className="input" value={editForm.throwFeet}
onChange={(e)=> setEditForm({...editForm, throwFeet: parseFloat(e.target.value||'0')})} />
</>
) : (
<>
<label className="text-xs">Length (ft)</label>
<input type="number" step="0.1" className="input" value={editForm.lengthFeet}
onChange={(e)=> setEditForm({...editForm, lengthFeet: parseFloat(e.target.value||'0')})} />
<label className="text-xs">Width (ft)</label>
<input type="number" step="0.1" className="input" value={editForm.widthFeet}
onChange={(e)=> setEditForm({...editForm, widthFeet: parseFloat(e.target.value||'0')})} />
<label className="text-xs">Heading</label>
<input type="number" className="input" value={editForm.headingDegrees}
onChange={(e)=> setEditForm({...editForm, headingDegrees: parseInt(e.target.value||'0',10)%360})} />
</>
)}
<label className="text-xs">Duration (min)</label>
<input type="number" className="input" value={editForm.durationMinutes}
onChange={(e)=> setEditForm({...editForm, durationMinutes: parseInt(e.target.value||'0',10)})} />
<div className="flex gap-2">
<button className="btn-primary flex-1" onClick={async ()=>{
await updatePointField(selectedPointId, editForm);
}}>Save</button>
<button className="btn-secondary" onClick={async ()=>{
await wateringAPI.deletePoint(selectedPointId);
setPoints(prev=> prev.filter(p=> p.id!==selectedPointId));
setSelectedPointId(null); setEditForm(null);
}}>Delete</button>
</div>
</div>
)}
</div>
</div>
<div className="lg:col-span-3">
<div className="card p-0" style={{height:'70vh'}}>
<div className="flex items-center justify-between p-3 border-b">
<div className="text-sm text-gray-700">{guiding && currentPos && points[guideIndex] ? (
<>Go to point #{points[guideIndex].sequence}: {distanceFeet(currentPos, {lat: Number(points[guideIndex].lat), lng: Number(points[guideIndex].lng)}).toFixed(0)} ft away</>
) : 'Map'}</div>
<div className="flex gap-2 items-center">
<label className="text-xs flex items-center gap-1">
<input type="checkbox" checked={satellite} onChange={(e)=> setSatellite(e.target.checked)} /> Satellite
</label>
{!guiding ? (
<button className="btn-secondary" onClick={startGuidance} disabled={points.length===0}>Start Guidance</button>
) : (
<>
<button className="btn-secondary" onClick={()=> setGuideIndex(i=> Math.max(0, i-1))} disabled={guideIndex===0}>Prev</button>
<button className="btn-secondary" onClick={()=> setGuideIndex(i=> Math.min(points.length-1, i+1))} disabled={guideIndex>=points.length-1}>Next</button>
<button className="btn-primary" onClick={stopGuidance}>Stop</button>
</>
)}
</div>
</div>
<MapContainer center={center} zoom={18} style={{height:'100%', width:'100%'}}>
<CenterOnChange center={center} />
{satellite ? (
<TileLayer url="https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}" />
) : (
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" />
)}
{sections.map((s)=> (
<Polygon key={s.id} positions={s.polygonData?.coordinates?.[0]||[]} pathOptions={{ color:'#16a34a', weight:2, fillOpacity:0.1 }} />
))}
{placing && <SprinklerPlacement onPlace={onPlace} />}
{points.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)
});
return (
<React.Fragment key={pt.id}>
<Marker position={[pt.lat, pt.lng]} draggable={true}
eventHandlers={{
dragend: (e)=>{
const ll = e.target.getLatLng();
updatePointField(pt.id, { lat: ll.lat, lng: ll.lng });
},
click: ()=> onSelectPoint(pt)
}}
/>
{cov?.kind==='circle' && (
cov.degrees && cov.degrees < 360 ? (
<Polygon positions={sectorPolygon({lat:Number(pt.lat),lng:Number(pt.lng)}, cov.radius, (pt.sprinkler_heading_degrees||0), (pt.sprinkler_heading_degrees||0)+cov.degrees)} pathOptions={{ color:'#2563eb', fillOpacity:0.2 }} />
) : (
<Circle center={[pt.lat, pt.lng]} radius={cov.radius*0.3048} pathOptions={{ color:'#2563eb', fillOpacity:0.2 }} />
)
)}
{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];
});
return <Polygon positions={corners} pathOptions={{ color:'#2563eb', fillOpacity:0.2 }} />;
})()}
</React.Fragment>
);
})}
</MapContainer>
</div>
</div>
</div>
</div>
);
};
export default Watering;