boundary entries updates
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { MapContainer, TileLayer, Marker, Popup, Polygon, useMapEvents, useMap } from 'react-leaflet';
|
||||
import { MapContainer, TileLayer, Marker, Popup, Polygon, Polyline, useMapEvents, useMap } from 'react-leaflet';
|
||||
import { Icon } from 'leaflet';
|
||||
import * as turf from '@turf/turf';
|
||||
import {
|
||||
@@ -344,6 +344,15 @@ const PropertyDetail = () => {
|
||||
const [lawnSections, setLawnSections] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isDrawing, setIsDrawing] = useState(false);
|
||||
// GPS capture modes
|
||||
const [isGPSPointsMode, setIsGPSPointsMode] = useState(false); // mark-at-location
|
||||
const [isGPSTraceMode, setIsGPSTraceMode] = useState(false); // continuous trace
|
||||
const [isTracing, setIsTracing] = useState(false);
|
||||
const [gpsWatchId, setGpsWatchId] = useState(null);
|
||||
const [gpsTracePoints, setGpsTracePoints] = useState([]);
|
||||
const [gpsDistance, setGpsDistance] = useState(0);
|
||||
const [gpsAccuracy, setGpsAccuracy] = useState(null);
|
||||
const [isSnapPreview, setIsSnapPreview] = useState(false);
|
||||
const [currentColor, setCurrentColor] = useState(SECTION_COLORS[0]);
|
||||
const [showNameModal, setShowNameModal] = useState(false);
|
||||
const [pendingSection, setPendingSection] = useState(null);
|
||||
@@ -453,6 +462,101 @@ const PropertyDetail = () => {
|
||||
setIsDrawing(false);
|
||||
};
|
||||
|
||||
// Haversine distance in meters
|
||||
const haversine = (lat1, lon1, lat2, lon2) => {
|
||||
const toRad = (d) => (d * Math.PI) / 180;
|
||||
const R = 6371000;
|
||||
const dLat = toRad(lat2 - lat1);
|
||||
const dLon = toRad(lon2 - lon1);
|
||||
const a = Math.sin(dLat/2)**2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon/2)**2;
|
||||
return 2 * R * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
};
|
||||
|
||||
// GPS filtering and snapping (defaults)
|
||||
const ACCURACY_MAX_METERS = 15; // Ignore points with accuracy worse than this
|
||||
const MIN_MOVE_METERS = 2; // Ignore points if user hasn't moved this far from last point
|
||||
const SNAP_METERS = 5; // Snap to starting point when within this distance
|
||||
|
||||
const acceptAndNormalizePoint = (lat, lng, accuracy, currentPoints) => {
|
||||
if (accuracy != null && accuracy > ACCURACY_MAX_METERS) {
|
||||
toast("GPS accuracy too low (" + Math.round(accuracy) + "m). Waiting for better fix…");
|
||||
return null;
|
||||
}
|
||||
// Movement threshold
|
||||
if (currentPoints.length > 0) {
|
||||
const [llat, llng] = currentPoints[currentPoints.length - 1];
|
||||
const moved = haversine(llat, llng, lat, lng);
|
||||
if (moved < MIN_MOVE_METERS) {
|
||||
// Too close to last point
|
||||
return null;
|
||||
}
|
||||
}
|
||||
// Snap to starting point if near
|
||||
if (currentPoints.length >= 2) {
|
||||
const [slat, slng] = currentPoints[0];
|
||||
const dStart = haversine(slat, slng, lat, lng);
|
||||
if (dStart <= SNAP_METERS) {
|
||||
return [slat, slng];
|
||||
}
|
||||
}
|
||||
return [lat, lng];
|
||||
};
|
||||
|
||||
// GPS point collection (mark-at-location)
|
||||
const markCurrentPoint = () => {
|
||||
if (!navigator.geolocation) { toast.error('GPS not available'); return; }
|
||||
navigator.geolocation.getCurrentPosition((pos) => {
|
||||
const { latitude, longitude, accuracy } = pos.coords;
|
||||
setGpsAccuracy(accuracy || null);
|
||||
setGpsTracePoints(prev => {
|
||||
const normalized = acceptAndNormalizePoint(latitude, longitude, accuracy, prev);
|
||||
// For preview: check proximity to start even if skipping
|
||||
if (prev.length >= 2) {
|
||||
const [slat, slng] = prev[0];
|
||||
const dStart = haversine(slat, slng, latitude, longitude);
|
||||
setIsSnapPreview(dStart <= SNAP_METERS);
|
||||
} else { setIsSnapPreview(false); }
|
||||
if (!normalized) return prev; // skip
|
||||
const next = [...prev, normalized];
|
||||
if (next.length > 1) {
|
||||
const [pl, pg] = next[next.length - 2];
|
||||
setGpsDistance(d => d + haversine(pl, pg, normalized[0], normalized[1]));
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, (err)=>{
|
||||
console.warn('GPS error', err?.message);
|
||||
toast.error('GPS error: ' + (err?.message || 'unknown'));
|
||||
}, { enableHighAccuracy: true, maximumAge: 1000, timeout: 10000 });
|
||||
};
|
||||
|
||||
const undoLastPoint = () => {
|
||||
setGpsTracePoints(prev => {
|
||||
if (prev.length <= 0) return prev;
|
||||
const next = prev.slice(0, -1);
|
||||
// Recompute distance
|
||||
let dist = 0;
|
||||
for (let i=1;i<next.length;i++) dist += haversine(next[i-1][0], next[i-1][1], next[i][0], next[i][1]);
|
||||
setGpsDistance(dist);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const clearGpsPoints = () => {
|
||||
setGpsTracePoints([]);
|
||||
setGpsDistance(0);
|
||||
setGpsAccuracy(null);
|
||||
};
|
||||
|
||||
const completeTracing = () => {
|
||||
if (gpsTracePoints.length < 3) { toast.error('Need at least 3 points to create an area'); return; }
|
||||
// Close polygon by ensuring first == last handled in save
|
||||
handlePolygonComplete(gpsTracePoints);
|
||||
setIsGPSTraceMode(false);
|
||||
setIsGPSPointsMode(false);
|
||||
clearGpsPoints();
|
||||
};
|
||||
|
||||
const saveLawnSection = async () => {
|
||||
if (!sectionName.trim()) {
|
||||
toast.error('Please enter a section name');
|
||||
@@ -469,7 +573,13 @@ const PropertyDetail = () => {
|
||||
},
|
||||
grassType: sectionGrassTypes.join(', '),
|
||||
grassTypes: sectionGrassTypes,
|
||||
soilType: null
|
||||
soilType: null,
|
||||
captureMethod: isGPSPointsMode ? 'gps_points' : (isGPSTraceMode ? 'gps_trace' : 'tap'),
|
||||
captureMeta: {
|
||||
accuracyLast: gpsAccuracy,
|
||||
totalDistanceMeters: gpsDistance,
|
||||
pointsCount: pendingSection.coordinates?.length || 0
|
||||
}
|
||||
};
|
||||
|
||||
const response = await propertiesAPI.createSection(id, sectionData);
|
||||
@@ -700,13 +810,40 @@ const PropertyDetail = () => {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsDrawing(!isDrawing)}
|
||||
className={`btn-primary flex items-center gap-2 ${isDrawing ? 'bg-red-600 hover:bg-red-700' : ''}`}
|
||||
>
|
||||
<PlusIcon className="h-5 w-5" />
|
||||
{isDrawing ? 'Cancel Drawing' : 'Add Lawn Section'}
|
||||
</button>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setIsDrawing(!isDrawing)}
|
||||
className={`btn-primary flex items-center gap-2 ${isDrawing ? 'bg-red-600 hover:bg-red-700' : ''}`}
|
||||
>
|
||||
<PlusIcon className="h-5 w-5" />
|
||||
{isDrawing ? 'Cancel Drawing' : 'Tap-to-Draw'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
// Toggle GPS points mode; ensure trace mode off
|
||||
setIsGPSTraceMode(false);
|
||||
setIsTracing(false);
|
||||
if (gpsWatchId) { navigator.geolocation.clearWatch(gpsWatchId); setGpsWatchId(null); }
|
||||
setIsGPSPointsMode(v=>!v);
|
||||
if (!isGPSPointsMode) clearGpsPoints();
|
||||
}}
|
||||
className={`btn-secondary flex items-center gap-2 ${isGPSPointsMode ? 'ring-2 ring-blue-300' : ''}`}
|
||||
>
|
||||
{isGPSPointsMode ? 'Exit GPS Points' : 'GPS Points'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
// Toggle trace mode; ensure points mode off
|
||||
setIsGPSPointsMode(false);
|
||||
if (!isGPSTraceMode) clearGpsPoints();
|
||||
if (isGPSTraceMode && gpsWatchId) { navigator.geolocation.clearWatch(gpsWatchId); setGpsWatchId(null); setIsTracing(false); }
|
||||
setIsGPSTraceMode(v=>!v);
|
||||
}}
|
||||
className={`btn-secondary flex items-center gap-2 ${isGPSTraceMode ? 'ring-2 ring-blue-300' : ''}`}
|
||||
>
|
||||
{isGPSTraceMode ? 'Exit Trace' : 'Trace Boundary'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||
@@ -749,6 +886,26 @@ const PropertyDetail = () => {
|
||||
currentColor={currentColor}
|
||||
/>
|
||||
)}
|
||||
{/* GPS trace overlays */}
|
||||
{(isGPSPointsMode || isGPSTraceMode) && gpsTracePoints.length > 0 && (
|
||||
<>
|
||||
<Polyline positions={gpsTracePoints} pathOptions={{ color: '#2563eb', weight: 3 }} />
|
||||
{gpsTracePoints.map((p, i) => (
|
||||
<Marker
|
||||
key={`g${i}`}
|
||||
position={p}
|
||||
icon={new Icon({
|
||||
iconUrl: 'data:image/svg+xml;base64,' + btoa(`
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" xmlns="http://www.w3.org/2000/svg">
|
||||
${i===0 && isSnapPreview ? '<circle cx="7" cy="7" r="6" fill="#fde68a" stroke="#f59e0b" stroke-width="2"/>' : '<circle cx="7" cy="7" r="6" fill="#2563eb" stroke="white" stroke-width="2"/>'}
|
||||
</svg>
|
||||
`),
|
||||
iconSize: [14,14], iconAnchor: [7,7]
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</MapContainer>
|
||||
</div>
|
||||
|
||||
@@ -768,6 +925,74 @@ const PropertyDetail = () => {
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{isGPSPointsMode && (
|
||||
<div className="p-4 bg-green-50 border-t">
|
||||
<div className="flex flex-wrap items-center gap-3 text-sm text-green-900">
|
||||
<span className="font-semibold">GPS Points Mode</span>
|
||||
<span>Points: {gpsTracePoints.length}</span>
|
||||
<span>Distance: {(gpsDistance * 3.28084).toFixed(0)} ft</span>
|
||||
{gpsAccuracy != null && <span>Accuracy: ±{Math.round(gpsAccuracy)} m</span>}
|
||||
</div>
|
||||
<div className="mt-3 flex gap-2 items-center">
|
||||
<button className="btn-primary" onClick={markCurrentPoint}>Mark Point</button>
|
||||
<button className="btn-secondary" onClick={undoLastPoint} disabled={gpsTracePoints.length===0}>Undo</button>
|
||||
<button className="btn-secondary" onClick={clearGpsPoints} disabled={gpsTracePoints.length===0}>Clear</button>
|
||||
<button className="btn-primary" onClick={completeTracing} disabled={gpsTracePoints.length < 3}>Complete Boundary</button>
|
||||
{isSnapPreview && <span className="text-xs text-amber-700 bg-amber-100 px-2 py-1 rounded">Snap to start available</span>}
|
||||
</div>
|
||||
<p className="text-xs text-green-700 mt-2">Walk to each corner, tap Mark Point, then Complete. You can refine points afterward.</p>
|
||||
</div>
|
||||
)}
|
||||
{isGPSTraceMode && (
|
||||
<div className="p-4 bg-green-50 border-t">
|
||||
<div className="flex flex-wrap items-center gap-3 text-sm text-green-900">
|
||||
<span className="font-semibold">Trace Mode</span>
|
||||
<span>Points: {gpsTracePoints.length}</span>
|
||||
<span>Distance: {(gpsDistance * 3.28084).toFixed(0)} ft</span>
|
||||
{gpsAccuracy != null && <span>Accuracy: ±{Math.round(gpsAccuracy)} m</span>}
|
||||
</div>
|
||||
<div className="mt-3 flex gap-2 items-center">
|
||||
{!isTracing ? (
|
||||
<button className="btn-primary" onClick={() => {
|
||||
if (!navigator.geolocation) { toast.error('GPS not available'); return; }
|
||||
const id = navigator.geolocation.watchPosition((pos) => {
|
||||
const { latitude, longitude, accuracy } = pos.coords;
|
||||
setGpsAccuracy(accuracy || null);
|
||||
setGpsTracePoints(prev => {
|
||||
const normalized = acceptAndNormalizePoint(latitude, longitude, accuracy, prev);
|
||||
if (prev.length >= 2) {
|
||||
const [slat, slng] = prev[0];
|
||||
const dStart = haversine(slat, slng, latitude, longitude);
|
||||
setIsSnapPreview(dStart <= SNAP_METERS);
|
||||
} else { setIsSnapPreview(false); }
|
||||
if (!normalized) return prev;
|
||||
const next = [...prev, normalized];
|
||||
if (next.length > 1) {
|
||||
const [pl, pg] = next[next.length - 2];
|
||||
setGpsDistance(d => d + haversine(pl, pg, normalized[0], normalized[1]));
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, (err)=>{
|
||||
console.warn('GPS error', err?.message);
|
||||
toast.error('GPS error: ' + (err?.message || 'unknown'));
|
||||
}, { enableHighAccuracy: true, maximumAge: 1000, timeout: 10000 });
|
||||
setGpsWatchId(id);
|
||||
setIsTracing(true);
|
||||
}}>Start</button>
|
||||
) : (
|
||||
<button className="btn-secondary" onClick={() => {
|
||||
if (gpsWatchId) { navigator.geolocation.clearWatch(gpsWatchId); setGpsWatchId(null); }
|
||||
setIsTracing(false);
|
||||
}}>Pause</button>
|
||||
)}
|
||||
<button className="btn-secondary" onClick={() => { if (gpsWatchId) { navigator.geolocation.clearWatch(gpsWatchId); setGpsWatchId(null);} setIsTracing(false); clearGpsPoints(); }} disabled={gpsTracePoints.length===0}>Clear</button>
|
||||
<button className="btn-primary" onClick={completeTracing} disabled={gpsTracePoints.length < 3}>Complete Boundary</button>
|
||||
{isSnapPreview && <span className="text-xs text-amber-700 bg-amber-100 px-2 py-1 rounded">Snap to start available</span>}
|
||||
</div>
|
||||
<p className="text-xs text-green-700 mt-2">Walk the boundary to trace it. Pause as needed, then Complete to create the area.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user