573 lines
18 KiB
JavaScript
573 lines
18 KiB
JavaScript
import React, { useState, useCallback, useRef } from 'react';
|
|
import { MapContainer, TileLayer, Polygon, Marker, Polyline, useMapEvents, useMap } from 'react-leaflet';
|
|
import { Icon } from 'leaflet';
|
|
import 'leaflet/dist/leaflet.css';
|
|
|
|
// Fix for default markers in react-leaflet
|
|
delete Icon.Default.prototype._getIconUrl;
|
|
Icon.Default.mergeOptions({
|
|
iconRetinaUrl: require('leaflet/dist/images/marker-icon-2x.png'),
|
|
iconUrl: require('leaflet/dist/images/marker-icon.png'),
|
|
shadowUrl: require('leaflet/dist/images/marker-shadow.png'),
|
|
});
|
|
|
|
// Custom GPS tracking icons
|
|
const currentLocationIcon = new Icon({
|
|
iconUrl: 'data:image/svg+xml;base64,' + btoa(`
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20">
|
|
<circle cx="10" cy="10" r="8" fill="#3B82F6" stroke="white" stroke-width="2"/>
|
|
<circle cx="10" cy="10" r="4" fill="white"/>
|
|
</svg>
|
|
`),
|
|
iconSize: [20, 20],
|
|
iconAnchor: [10, 10],
|
|
});
|
|
|
|
const trackPointIcon = new Icon({
|
|
iconUrl: 'data:image/svg+xml;base64,' + btoa(`
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="6" height="6" viewBox="0 0 6 6">
|
|
<circle cx="3" cy="3" r="2" fill="#10B981"/>
|
|
</svg>
|
|
`),
|
|
iconSize: [6, 6],
|
|
iconAnchor: [3, 3],
|
|
});
|
|
|
|
// Custom component to handle map clicks for drawing polygons
|
|
const DrawingHandler = ({ isDrawing, onPointAdd, onDrawingComplete }) => {
|
|
useMapEvents({
|
|
click: (e) => {
|
|
if (isDrawing) {
|
|
onPointAdd([e.latlng.lat, e.latlng.lng]);
|
|
}
|
|
},
|
|
keydown: (e) => {
|
|
if (e.originalEvent.key === 'Escape' && isDrawing) {
|
|
onDrawingComplete();
|
|
}
|
|
}
|
|
});
|
|
return null;
|
|
};
|
|
|
|
// Keep map centered when props change
|
|
const MapViewUpdater = ({ center, zoom }) => {
|
|
const map = useMap();
|
|
const prev = useRef({ center: null, zoom: null });
|
|
React.useEffect(() => {
|
|
const [lat, lng] = center || [];
|
|
const isValid = typeof lat === 'number' && typeof lng === 'number';
|
|
const zoomToUse = typeof zoom === 'number' ? zoom : map.getZoom();
|
|
const changed =
|
|
!prev.current.center ||
|
|
prev.current.center[0] !== lat ||
|
|
prev.current.center[1] !== lng ||
|
|
prev.current.zoom !== zoomToUse;
|
|
if (isValid && changed) {
|
|
map.setView(center, zoomToUse, { animate: true });
|
|
prev.current = { center, zoom: zoomToUse };
|
|
}
|
|
}, [center, zoom, map]);
|
|
return null;
|
|
};
|
|
|
|
// Calculate polygon area in square feet
|
|
const calculatePolygonArea = (coordinates) => {
|
|
if (!coordinates || coordinates.length < 3) return 0;
|
|
|
|
// Shoelace formula for polygon area
|
|
let area = 0;
|
|
const n = coordinates.length;
|
|
|
|
for (let i = 0; i < n; i++) {
|
|
const j = (i + 1) % n;
|
|
area += coordinates[i][0] * coordinates[j][1];
|
|
area -= coordinates[j][0] * coordinates[i][1];
|
|
}
|
|
|
|
area = Math.abs(area) / 2;
|
|
|
|
// Convert from decimal degrees to square feet (approximate)
|
|
const avgLat = coordinates.reduce((sum, coord) => sum + coord[0], 0) / n;
|
|
const meterToFeet = 3.28084;
|
|
const degToMeter = 111320 * Math.cos(avgLat * Math.PI / 180);
|
|
|
|
return area * Math.pow(degToMeter * meterToFeet, 2);
|
|
};
|
|
|
|
const PropertyMap = ({
|
|
center = [39.8283, -98.5795], // Center of USA as default
|
|
zoom = 15,
|
|
property,
|
|
sections = [],
|
|
onSectionCreate,
|
|
onSectionUpdate,
|
|
onSectionDelete,
|
|
onPropertyUpdate,
|
|
onSectionClick,
|
|
selectedSections = [],
|
|
editable = false,
|
|
className = "h-96 w-full",
|
|
// GPS tracking props
|
|
mode = "view", // "view", "edit", "execution"
|
|
gpsTrack = [],
|
|
currentLocation = null,
|
|
showTrackPoints = true
|
|
}) => {
|
|
|
|
// Debug logging
|
|
console.log('PropertyMap render:', {
|
|
center,
|
|
zoom,
|
|
sections: sections.length,
|
|
selectedSections,
|
|
mode,
|
|
gpsTrack: gpsTrack.length,
|
|
currentLocation
|
|
});
|
|
const [isDrawing, setIsDrawing] = useState(false);
|
|
const [currentPolygon, setCurrentPolygon] = useState([]);
|
|
const [selectedSection, setSelectedSection] = useState(null);
|
|
const [showSectionForm, setShowSectionForm] = useState(false);
|
|
const [sectionName, setSectionName] = useState('');
|
|
const [grassType, setGrassType] = useState('');
|
|
const [soilType, setSoilType] = useState('');
|
|
const mapRef = useRef(null);
|
|
|
|
// Handle adding points to current polygon
|
|
const handlePointAdd = useCallback((point) => {
|
|
setCurrentPolygon(prev => [...prev, point]);
|
|
}, []);
|
|
|
|
// Handle completing polygon drawing
|
|
const handleDrawingComplete = useCallback(() => {
|
|
if (currentPolygon.length >= 3) {
|
|
const area = calculatePolygonArea(currentPolygon);
|
|
setShowSectionForm(true);
|
|
} else {
|
|
setCurrentPolygon([]);
|
|
}
|
|
setIsDrawing(false);
|
|
}, [currentPolygon]);
|
|
|
|
// Start drawing mode
|
|
const startDrawing = () => {
|
|
setIsDrawing(true);
|
|
setCurrentPolygon([]);
|
|
setSelectedSection(null);
|
|
};
|
|
|
|
// Cancel drawing
|
|
const cancelDrawing = () => {
|
|
setIsDrawing(false);
|
|
setCurrentPolygon([]);
|
|
setShowSectionForm(false);
|
|
setSectionName('');
|
|
setGrassType('');
|
|
setSoilType('');
|
|
};
|
|
|
|
// Save new section
|
|
const saveSection = async () => {
|
|
if (!sectionName.trim() || currentPolygon.length < 3) return;
|
|
|
|
const area = calculatePolygonArea(currentPolygon);
|
|
const sectionData = {
|
|
name: sectionName,
|
|
area,
|
|
polygonData: {
|
|
type: 'Polygon',
|
|
coordinates: [currentPolygon.map(([lat, lng]) => [lng, lat])] // GeoJSON format
|
|
},
|
|
grassType: grassType || null,
|
|
soilType: soilType || null
|
|
};
|
|
|
|
try {
|
|
if (onSectionCreate) {
|
|
await onSectionCreate(sectionData);
|
|
}
|
|
|
|
// Reset form
|
|
cancelDrawing();
|
|
} catch (error) {
|
|
console.error('Error creating section:', error);
|
|
}
|
|
};
|
|
|
|
// Handle section click
|
|
const handleSectionClick = (section) => {
|
|
if (onSectionClick) {
|
|
// For application planning mode - use the provided callback
|
|
onSectionClick(section);
|
|
} else {
|
|
// For editing mode - use the internal state
|
|
setSelectedSection(selectedSection?.id === section.id ? null : section);
|
|
}
|
|
};
|
|
|
|
// Delete selected section
|
|
const deleteSelectedSection = async () => {
|
|
if (!selectedSection || !onSectionDelete) return;
|
|
|
|
try {
|
|
await onSectionDelete(selectedSection.id);
|
|
setSelectedSection(null);
|
|
} catch (error) {
|
|
console.error('Error deleting section:', error);
|
|
}
|
|
};
|
|
|
|
// Get section color based on grass type
|
|
const getSectionColor = (section) => {
|
|
const colors = {
|
|
'bermuda': '#10b981',
|
|
'fescue': '#059669',
|
|
'kentucky bluegrass': '#047857',
|
|
'zoysia': '#065f46',
|
|
'st augustine': '#064e3b',
|
|
'centipede': '#6ee7b7',
|
|
'default': '#3b82f6'
|
|
};
|
|
|
|
return colors[section.grassType?.toLowerCase()] || colors.default;
|
|
};
|
|
|
|
return (
|
|
<div className={`relative ${className}`}>
|
|
{/* Map Controls */}
|
|
{editable && (
|
|
<div className="absolute top-4 right-4 z-10 flex flex-col space-y-2">
|
|
<div className="bg-white rounded-lg shadow-lg p-2">
|
|
{!isDrawing ? (
|
|
<button
|
|
onClick={startDrawing}
|
|
className="btn-primary text-sm px-3 py-2"
|
|
title="Draw new lawn section"
|
|
>
|
|
Draw Section
|
|
</button>
|
|
) : (
|
|
<div className="flex flex-col space-y-2">
|
|
<button
|
|
onClick={handleDrawingComplete}
|
|
className="btn-success text-sm px-3 py-2"
|
|
disabled={currentPolygon.length < 3}
|
|
title="Finish drawing (or press Escape)"
|
|
>
|
|
Finish ({currentPolygon.length} points)
|
|
</button>
|
|
<button
|
|
onClick={cancelDrawing}
|
|
className="btn-secondary text-sm px-3 py-2"
|
|
title="Cancel drawing"
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{selectedSection && (
|
|
<div className="bg-white rounded-lg shadow-lg p-3">
|
|
<h4 className="font-medium text-gray-900 mb-2">{selectedSection.name}</h4>
|
|
<p className="text-sm text-gray-600 mb-2">
|
|
{Math.round(selectedSection.area).toLocaleString()} sq ft
|
|
</p>
|
|
{selectedSection.grassType && (
|
|
<p className="text-xs text-gray-500 mb-1">
|
|
Grass: {selectedSection.grassType}
|
|
</p>
|
|
)}
|
|
{selectedSection.soilType && (
|
|
<p className="text-xs text-gray-500 mb-3">
|
|
Soil: {selectedSection.soilType}
|
|
</p>
|
|
)}
|
|
<button
|
|
onClick={deleteSelectedSection}
|
|
className="btn-danger text-xs px-2 py-1 w-full"
|
|
>
|
|
Delete Section
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Section Creation Form Modal */}
|
|
{showSectionForm && (
|
|
<div className="absolute inset-0 z-20 flex items-center justify-center bg-black bg-opacity-50">
|
|
<div className="bg-white rounded-lg p-6 max-w-md w-full mx-4">
|
|
<h3 className="text-lg font-medium text-gray-900 mb-4">
|
|
New Lawn Section
|
|
</h3>
|
|
|
|
<div className="space-y-4">
|
|
<div>
|
|
<label className="label">Section Name *</label>
|
|
<input
|
|
type="text"
|
|
value={sectionName}
|
|
onChange={(e) => setSectionName(e.target.value)}
|
|
className="input"
|
|
placeholder="e.g., Front Yard, Back Lawn"
|
|
autoFocus
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="label">Grass Type</label>
|
|
<select
|
|
value={grassType}
|
|
onChange={(e) => setGrassType(e.target.value)}
|
|
className="input"
|
|
>
|
|
<option value="">Select grass type</option>
|
|
<option value="bermuda">Bermuda</option>
|
|
<option value="fescue">Fescue</option>
|
|
<option value="kentucky bluegrass">Kentucky Bluegrass</option>
|
|
<option value="zoysia">Zoysia</option>
|
|
<option value="st augustine">St. Augustine</option>
|
|
<option value="centipede">Centipede</option>
|
|
<option value="other">Other</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="label">Soil Type</label>
|
|
<select
|
|
value={soilType}
|
|
onChange={(e) => setSoilType(e.target.value)}
|
|
className="input"
|
|
>
|
|
<option value="">Select soil type</option>
|
|
<option value="clay">Clay</option>
|
|
<option value="sand">Sand</option>
|
|
<option value="loam">Loam</option>
|
|
<option value="silt">Silt</option>
|
|
<option value="rocky">Rocky</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div className="bg-gray-50 p-3 rounded-lg">
|
|
<p className="text-sm text-gray-600">
|
|
Area: {Math.round(calculatePolygonArea(currentPolygon)).toLocaleString()} sq ft
|
|
</p>
|
|
<p className="text-xs text-gray-500 mt-1">
|
|
{currentPolygon.length} points drawn
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex justify-end space-x-3 mt-6">
|
|
<button
|
|
onClick={cancelDrawing}
|
|
className="btn-outline"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
onClick={saveSection}
|
|
disabled={!sectionName.trim()}
|
|
className="btn-primary"
|
|
>
|
|
Save Section
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Map */}
|
|
<MapContainer
|
|
center={center}
|
|
zoom={zoom}
|
|
className="h-full w-full rounded-lg"
|
|
ref={mapRef}
|
|
>
|
|
{/* Use OpenStreetMap as fallback for better reliability */}
|
|
<TileLayer
|
|
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
|
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
|
/>
|
|
|
|
{/* Sync map view with props */}
|
|
<MapViewUpdater center={center} zoom={zoom} />
|
|
|
|
{/* Drawing handler */}
|
|
<DrawingHandler
|
|
isDrawing={isDrawing}
|
|
onPointAdd={handlePointAdd}
|
|
onDrawingComplete={handleDrawingComplete}
|
|
/>
|
|
|
|
{/* Property marker */}
|
|
{property && property.latitude && property.longitude && (
|
|
<Marker
|
|
position={[property.latitude, property.longitude]}
|
|
title={property.name}
|
|
/>
|
|
)}
|
|
|
|
{/* Existing sections */}
|
|
{sections.map((section) => {
|
|
// Handle both string and object polygon data
|
|
let polygonData = section.polygonData;
|
|
if (typeof polygonData === 'string') {
|
|
try {
|
|
polygonData = JSON.parse(polygonData);
|
|
} catch (e) {
|
|
console.error('Failed to parse polygon data:', e);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
if (!polygonData?.coordinates?.[0]) {
|
|
return null;
|
|
}
|
|
|
|
// Coordinates are already in [lat, lng] format - no swapping needed!
|
|
const coordinates = polygonData.coordinates[0];
|
|
|
|
const isInternallySelected = selectedSection?.id === section.id;
|
|
const isExternallySelected = selectedSections.includes(section.id);
|
|
const isSelected = isInternallySelected || isExternallySelected;
|
|
|
|
return (
|
|
<Polygon
|
|
key={section.id}
|
|
positions={coordinates}
|
|
pathOptions={{
|
|
fillColor: isExternallySelected ? '#10b981' : '#3b82f6',
|
|
fillOpacity: isSelected ? 0.8 : 0.5,
|
|
color: isExternallySelected ? '#059669' : '#2563eb',
|
|
weight: isSelected ? 4 : 3,
|
|
opacity: 1,
|
|
}}
|
|
eventHandlers={{
|
|
click: () => handleSectionClick(section)
|
|
}}
|
|
/>
|
|
);
|
|
})}
|
|
|
|
{/* GPS Tracking Elements */}
|
|
{(mode === "execution" || mode === "view") && (
|
|
<>
|
|
{/* GPS Track Polyline */}
|
|
{gpsTrack.length > 1 && (
|
|
<Polyline
|
|
positions={gpsTrack.map(point => [point.lat, point.lng])}
|
|
pathOptions={{
|
|
color: '#EF4444',
|
|
weight: 4,
|
|
opacity: 0.8,
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{/* Track Points (breadcrumbs) */}
|
|
{showTrackPoints && gpsTrack.map((point, index) => (
|
|
index % 10 === 0 && ( // Show every 10th point to avoid clutter
|
|
<Marker
|
|
key={`track-${index}`}
|
|
position={[point.lat, point.lng]}
|
|
icon={trackPointIcon}
|
|
/>
|
|
)
|
|
))}
|
|
|
|
{/* Current Location - only for execution mode */}
|
|
{currentLocation && mode === "execution" && (
|
|
<Marker
|
|
position={[currentLocation.lat, currentLocation.lng]}
|
|
icon={currentLocationIcon}
|
|
/>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{/* Current polygon being drawn */}
|
|
{currentPolygon.length > 0 && (
|
|
<>
|
|
{/* Show markers for each point */}
|
|
{currentPolygon.map((point, index) => (
|
|
<Marker
|
|
key={index}
|
|
position={point}
|
|
icon={new Icon({
|
|
iconUrl: 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPGNpcmNsZSBjeD0iMTIiIGN5PSIxMiIgcj0iNCIgZmlsbD0iIzNiODJmNiIgc3Ryb2tlPSJ3aGl0ZSIgc3Ryb2tlLXdpZHRoPSIyIi8+Cjwvc3ZnPgo=',
|
|
iconSize: [12, 12],
|
|
iconAnchor: [6, 6]
|
|
})}
|
|
/>
|
|
))}
|
|
|
|
{/* Show polygon if we have enough points */}
|
|
{currentPolygon.length >= 3 && (
|
|
<Polygon
|
|
positions={currentPolygon}
|
|
pathOptions={{
|
|
fillColor: '#3b82f6',
|
|
fillOpacity: 0.3,
|
|
color: '#3b82f6',
|
|
weight: 2,
|
|
dashArray: '5, 5'
|
|
}}
|
|
/>
|
|
)}
|
|
</>
|
|
)}
|
|
</MapContainer>
|
|
|
|
{/* Drawing instructions */}
|
|
{isDrawing && (
|
|
<div className="absolute bottom-4 left-4 bg-white rounded-lg shadow-lg p-3 max-w-xs">
|
|
<p className="text-sm text-gray-700">
|
|
<strong>Drawing Mode:</strong>
|
|
</p>
|
|
<p className="text-xs text-gray-600 mt-1">
|
|
Click to add points. Press <kbd className="px-1 py-0.5 bg-gray-100 rounded text-xs">Escape</kbd> or
|
|
click "Finish" when done.
|
|
</p>
|
|
<p className="text-xs text-gray-500 mt-1">
|
|
Need at least 3 points to create a section.
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* GPS Tracking stats */}
|
|
{mode === "execution" && gpsTrack.length > 0 && (
|
|
<div className="absolute bottom-4 left-4 bg-white rounded-lg shadow-lg p-3">
|
|
<p className="text-sm font-medium text-gray-700">
|
|
GPS Tracking Active
|
|
</p>
|
|
<p className="text-xs text-gray-600">
|
|
Track Points: {gpsTrack.length}
|
|
</p>
|
|
{currentLocation && (
|
|
<p className="text-xs text-gray-600">
|
|
Accuracy: ±{Math.round(currentLocation.accuracy || 0)}m
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Section stats */}
|
|
{sections.length > 0 && !isDrawing && mode !== "execution" && (
|
|
<div className="absolute bottom-4 left-4 bg-white rounded-lg shadow-lg p-3">
|
|
<p className="text-sm font-medium text-gray-700">
|
|
{sections.length} Section{sections.length !== 1 ? 's' : ''}
|
|
</p>
|
|
<p className="text-xs text-gray-600">
|
|
Total: {sections.reduce((sum, section) => sum + (section.area || 0), 0).toLocaleString()} sq ft
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default PropertyMap;
|