polygons
This commit is contained in:
@@ -15,6 +15,7 @@
|
|||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"react-leaflet": "^4.2.1",
|
"react-leaflet": "^4.2.1",
|
||||||
"leaflet-draw": "^1.0.4",
|
"leaflet-draw": "^1.0.4",
|
||||||
|
"@turf/turf": "^6.5.0",
|
||||||
"@headlessui/react": "^1.7.17",
|
"@headlessui/react": "^1.7.17",
|
||||||
"@heroicons/react": "^2.0.18",
|
"@heroicons/react": "^2.0.18",
|
||||||
"react-hook-form": "^7.48.2",
|
"react-hook-form": "^7.48.2",
|
||||||
|
|||||||
@@ -1,15 +1,391 @@
|
|||||||
import React from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
|
import { MapContainer, TileLayer, Marker, Popup, Polygon, useMapEvents } from 'react-leaflet';
|
||||||
|
import { Icon } from 'leaflet';
|
||||||
|
import * as turf from '@turf/turf';
|
||||||
|
import {
|
||||||
|
PlusIcon,
|
||||||
|
TrashIcon,
|
||||||
|
ArrowLeftIcon,
|
||||||
|
MapPinIcon,
|
||||||
|
SwatchIcon
|
||||||
|
} from '@heroicons/react/24/outline';
|
||||||
|
import { propertiesAPI } from '../../services/api';
|
||||||
|
import LoadingSpinner from '../../components/UI/LoadingSpinner';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
import 'leaflet/dist/leaflet.css';
|
||||||
|
|
||||||
|
// Fix for default markers
|
||||||
|
delete Icon.Default.prototype._getIconUrl;
|
||||||
|
Icon.Default.mergeOptions({
|
||||||
|
iconRetinaUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon-2x.png',
|
||||||
|
iconUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon.png',
|
||||||
|
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-shadow.png',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Colors for lawn sections
|
||||||
|
const SECTION_COLORS = [
|
||||||
|
{ name: 'Green', value: '#22c55e' },
|
||||||
|
{ name: 'Blue', value: '#3b82f6' },
|
||||||
|
{ name: 'Red', value: '#ef4444' },
|
||||||
|
{ name: 'Yellow', value: '#eab308' },
|
||||||
|
{ name: 'Purple', value: '#a855f7' },
|
||||||
|
{ name: 'Orange', value: '#f97316' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Calculate area in square feet
|
||||||
|
const calculateAreaInSqFt = (coordinates) => {
|
||||||
|
try {
|
||||||
|
const polygon = turf.polygon([coordinates.map(coord => [coord[1], coord[0]])]);
|
||||||
|
const areaInSqMeters = turf.area(polygon);
|
||||||
|
return Math.round(areaInSqMeters * 10.7639); // Convert to sq ft
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Area calculation error:', error);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Component for drawing polygons
|
||||||
|
function PolygonDrawer({ isDrawing, onPolygonComplete, currentColor }) {
|
||||||
|
const [currentPolygon, setCurrentPolygon] = useState([]);
|
||||||
|
|
||||||
|
useMapEvents({
|
||||||
|
click(e) {
|
||||||
|
if (!isDrawing) return;
|
||||||
|
|
||||||
|
const newPoint = [e.latlng.lat, e.latlng.lng];
|
||||||
|
setCurrentPolygon(prev => [...prev, newPoint]);
|
||||||
|
},
|
||||||
|
dblclick() {
|
||||||
|
if (isDrawing && currentPolygon.length >= 3) {
|
||||||
|
onPolygonComplete(currentPolygon);
|
||||||
|
setCurrentPolygon([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return currentPolygon.length > 0 ? (
|
||||||
|
<Polygon
|
||||||
|
positions={currentPolygon}
|
||||||
|
pathOptions={{
|
||||||
|
color: currentColor.value,
|
||||||
|
fillColor: currentColor.value,
|
||||||
|
fillOpacity: 0.3,
|
||||||
|
weight: 2,
|
||||||
|
dashArray: '5, 5'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null;
|
||||||
|
}
|
||||||
|
|
||||||
const PropertyDetail = () => {
|
const PropertyDetail = () => {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [property, setProperty] = useState(null);
|
||||||
|
const [lawnSections, setLawnSections] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [isDrawing, setIsDrawing] = useState(false);
|
||||||
|
const [currentColor, setCurrentColor] = useState(SECTION_COLORS[0]);
|
||||||
|
const [showNameModal, setShowNameModal] = useState(false);
|
||||||
|
const [pendingSection, setPendingSection] = useState(null);
|
||||||
|
const [sectionName, setSectionName] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchPropertyDetails();
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
const fetchPropertyDetails = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const response = await propertiesAPI.getById(id);
|
||||||
|
console.log('Property details:', response);
|
||||||
|
setProperty(response.data.data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch property:', error);
|
||||||
|
toast.error('Failed to load property');
|
||||||
|
navigate('/properties');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePolygonComplete = (coordinates) => {
|
||||||
|
if (coordinates.length < 3) {
|
||||||
|
toast.error('Polygon needs at least 3 points');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const area = calculateAreaInSqFt([...coordinates, coordinates[0]]);
|
||||||
|
setPendingSection({ coordinates, color: currentColor, area });
|
||||||
|
setShowNameModal(true);
|
||||||
|
setIsDrawing(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveLawnSection = () => {
|
||||||
|
if (!sectionName.trim()) {
|
||||||
|
toast.error('Please enter a section name');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newSection = {
|
||||||
|
id: Date.now(),
|
||||||
|
name: sectionName,
|
||||||
|
coordinates: pendingSection.coordinates,
|
||||||
|
color: pendingSection.color,
|
||||||
|
area: pendingSection.area
|
||||||
|
};
|
||||||
|
|
||||||
|
setLawnSections(prev => [...prev, newSection]);
|
||||||
|
toast.success(`${sectionName} section created!`);
|
||||||
|
|
||||||
|
// Reset and cycle color
|
||||||
|
setSectionName('');
|
||||||
|
setPendingSection(null);
|
||||||
|
setShowNameModal(false);
|
||||||
|
const nextIndex = (SECTION_COLORS.findIndex(c => c.value === currentColor.value) + 1) % SECTION_COLORS.length;
|
||||||
|
setCurrentColor(SECTION_COLORS[nextIndex]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteLawnSection = (sectionId) => {
|
||||||
|
if (window.confirm('Delete this lawn section?')) {
|
||||||
|
setLawnSections(prev => prev.filter(s => s.id !== sectionId));
|
||||||
|
toast.success('Section deleted');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTotalArea = () => {
|
||||||
|
return lawnSections.reduce((total, section) => total + section.area, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex justify-center items-center h-64">
|
||||||
|
<LoadingSpinner size="lg" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!property) {
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="card text-center py-12">
|
||||||
|
<h3 className="text-lg font-medium text-gray-900 mb-2">Property Not Found</h3>
|
||||||
|
<button onClick={() => navigate('/properties')} className="btn-primary">
|
||||||
|
Back to Properties
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<h1 className="text-2xl font-bold text-gray-900 mb-6">Property Details</h1>
|
{/* Header */}
|
||||||
<div className="card">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<p className="text-gray-600">Property {id} details coming soon...</p>
|
<div className="flex items-center gap-4">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/properties')}
|
||||||
|
className="p-2 hover:bg-gray-100 rounded-lg"
|
||||||
|
>
|
||||||
|
<ArrowLeftIcon className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">{property.name}</h1>
|
||||||
|
<p className="text-gray-600 flex items-center gap-1">
|
||||||
|
<MapPinIcon className="h-4 w-4" />
|
||||||
|
{property.address}
|
||||||
|
</p>
|
||||||
|
</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>
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||||
|
{/* Map */}
|
||||||
|
<div className="lg:col-span-3">
|
||||||
|
<div className="card p-0 overflow-hidden">
|
||||||
|
<div style={{ height: '600px', width: '100%' }}>
|
||||||
|
<MapContainer
|
||||||
|
center={[property.latitude, property.longitude]}
|
||||||
|
zoom={19}
|
||||||
|
style={{ height: '100%', width: '100%' }}
|
||||||
|
>
|
||||||
|
<TileLayer
|
||||||
|
attribution='© <a href="https://www.esri.com/">Esri</a>'
|
||||||
|
url="https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Marker position={[property.latitude, property.longitude]}>
|
||||||
|
<Popup>{property.name}</Popup>
|
||||||
|
</Marker>
|
||||||
|
|
||||||
|
{lawnSections.map((section) => (
|
||||||
|
<Polygon
|
||||||
|
key={section.id}
|
||||||
|
positions={section.coordinates}
|
||||||
|
pathOptions={{
|
||||||
|
color: section.color.value,
|
||||||
|
fillColor: section.color.value,
|
||||||
|
fillOpacity: 0.4,
|
||||||
|
weight: 2
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Popup>
|
||||||
|
<div className="text-center">
|
||||||
|
<strong>{section.name}</strong><br />
|
||||||
|
{section.area.toLocaleString()} sq ft<br />
|
||||||
|
<button
|
||||||
|
onClick={() => deleteLawnSection(section.id)}
|
||||||
|
className="text-red-600 text-sm mt-2"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Popup>
|
||||||
|
</Polygon>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{isDrawing && (
|
||||||
|
<PolygonDrawer
|
||||||
|
isDrawing={isDrawing}
|
||||||
|
onPolygonComplete={handlePolygonComplete}
|
||||||
|
currentColor={currentColor}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</MapContainer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isDrawing && (
|
||||||
|
<div className="p-4 bg-blue-50">
|
||||||
|
<p className="text-sm text-blue-800">
|
||||||
|
<strong>Drawing Mode:</strong> Click to add points. Double-click to complete polygon.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sidebar */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Color Selector */}
|
||||||
|
{isDrawing && (
|
||||||
|
<div className="card">
|
||||||
|
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||||
|
<SwatchIcon className="h-5 w-5" />
|
||||||
|
Section Color
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{SECTION_COLORS.map((color) => (
|
||||||
|
<button
|
||||||
|
key={color.value}
|
||||||
|
onClick={() => setCurrentColor(color)}
|
||||||
|
className={`w-10 h-10 rounded border-2 ${
|
||||||
|
currentColor.value === color.value ? 'border-gray-900' : 'border-gray-300'
|
||||||
|
}`}
|
||||||
|
style={{ backgroundColor: color.value }}
|
||||||
|
title={color.name}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Property Summary */}
|
||||||
|
<div className="card">
|
||||||
|
<h3 className="text-lg font-semibold mb-4">Property Summary</h3>
|
||||||
|
<div className="space-y-2 text-sm">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-600">Total Sections:</span>
|
||||||
|
<span className="font-medium">{lawnSections.length}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-600">Total Area:</span>
|
||||||
|
<span className="font-medium">{getTotalArea().toLocaleString()} sq ft</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Lawn Sections */}
|
||||||
|
<div className="card">
|
||||||
|
<h3 className="text-lg font-semibold mb-4">Lawn Sections</h3>
|
||||||
|
{lawnSections.length === 0 ? (
|
||||||
|
<p className="text-gray-500 text-sm">No sections yet.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{lawnSections.map((section) => (
|
||||||
|
<div key={section.id} className="flex items-center justify-between p-2 bg-gray-50 rounded">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
className="w-4 h-4 rounded"
|
||||||
|
style={{ backgroundColor: section.color.value }}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-sm">{section.name}</p>
|
||||||
|
<p className="text-xs text-gray-600">{section.area.toLocaleString()} sq ft</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => deleteLawnSection(section.id)}
|
||||||
|
className="text-red-600 hover:text-red-800"
|
||||||
|
>
|
||||||
|
<TrashIcon className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Name Modal */}
|
||||||
|
{showNameModal && (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-white rounded-lg p-6 w-96">
|
||||||
|
<h3 className="text-lg font-semibold mb-4">Name Your Lawn Section</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input"
|
||||||
|
value={sectionName}
|
||||||
|
onChange={(e) => setSectionName(e.target.value)}
|
||||||
|
placeholder="e.g., Front Yard, Back Lawn"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<div
|
||||||
|
className="w-4 h-4 rounded"
|
||||||
|
style={{ backgroundColor: pendingSection?.color.value }}
|
||||||
|
/>
|
||||||
|
<span>{pendingSection?.area.toLocaleString()} sq ft</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3 mt-6">
|
||||||
|
<button onClick={saveLawnSection} className="btn-primary flex-1">
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowNameModal(false);
|
||||||
|
setPendingSection(null);
|
||||||
|
setSectionName('');
|
||||||
|
}}
|
||||||
|
className="btn-secondary flex-1"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user